handoff-app 2.0.0-8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{Changelog.md → CHANGELOG.md} +85 -1
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +816 -393
- package/RECIPES.md +975 -0
- package/UPGRADE.md +57 -9
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/connections.d.ts +66 -0
- package/dist/ai/connections.js +149 -0
- package/dist/ai/define.d.ts +10 -0
- package/dist/ai/define.js +13 -0
- package/dist/ai/types.d.ts +26 -0
- package/dist/ai/types.js +10 -0
- package/dist/app-builder/build.d.ts +14 -7
- package/dist/app-builder/build.js +195 -66
- package/dist/app-builder/client-config.js +3 -2
- package/dist/app-builder/paths.d.ts +0 -12
- package/dist/app-builder/paths.js +3 -20
- package/dist/app-builder/static-export.js +1 -1
- package/dist/app-builder/vercel-output.d.ts +1 -1
- package/dist/app-builder/vercel-output.js +5 -4
- package/dist/cache/build-cache.d.ts +27 -4
- package/dist/cache/build-cache.js +114 -47
- package/dist/cache/build-inputs.d.ts +13 -0
- package/dist/cache/build-inputs.js +19 -0
- package/dist/cache/index.d.ts +2 -1
- package/dist/cache/index.js +5 -1
- package/dist/catalog/normalize.js +3 -0
- package/dist/catalog/previews.js +5 -4
- package/dist/catalog/types.d.ts +8 -0
- package/dist/cli/create.js +1 -2
- package/dist/commands/build/index.d.ts +1 -1
- package/dist/commands/build/index.js +1 -1
- package/dist/commands/utils.d.ts +3 -3
- package/dist/config/defaults.js +7 -4
- package/dist/config/from-env.d.ts +4 -0
- package/dist/config/from-env.js +38 -1
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +0 -1
- package/dist/config/index.js +1 -4
- package/dist/config/loader.js +28 -2
- package/dist/config/resolve-env.d.ts +2 -1
- package/dist/config/resolve-env.js +28 -12
- package/dist/config/runtime.js +9 -11
- package/dist/handlebars.d.ts +10 -2
- package/dist/index.d.ts +12 -7
- package/dist/index.js +18 -22
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/pipeline/figma.d.ts +13 -2
- package/dist/pipeline/figma.js +86 -46
- package/dist/pipeline/styles.js +1 -1
- package/dist/pipeline/validation.d.ts +1 -1
- package/dist/pipeline/validation.js +3 -3
- package/dist/providers.d.ts +10 -0
- package/dist/providers.js +13 -0
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +9 -19
- package/dist/registry/asset-storage/resolve.js +33 -21
- package/dist/registry/asset-storage/types.d.ts +37 -17
- package/dist/registry/asset-storage/types.js +5 -5
- package/dist/registry/auth/ai-keys.d.ts +32 -0
- package/dist/registry/auth/ai-keys.js +69 -0
- package/dist/registry/auth/crypto.d.ts +17 -0
- package/dist/registry/auth/crypto.js +61 -1
- package/dist/registry/auth/index.d.ts +2 -1
- package/dist/registry/auth/index.js +11 -2
- package/dist/registry/checkout/index.d.ts +2 -1
- package/dist/registry/checkout/index.js +57 -127
- package/dist/registry/client.js +53 -17
- package/dist/registry/db/driver.js +3 -3
- package/dist/registry/db/schema.d.ts +261 -3
- package/dist/registry/db/schema.js +29 -4
- package/dist/registry/email/define.d.ts +10 -0
- package/dist/registry/email/define.js +13 -0
- package/dist/registry/email/providers/smtp.d.ts +10 -0
- package/dist/registry/email/providers/smtp.js +73 -0
- package/dist/registry/email/resolve.d.ts +28 -0
- package/dist/registry/email/resolve.js +74 -0
- package/dist/registry/email/types.d.ts +24 -0
- package/dist/registry/email/types.js +6 -0
- package/dist/registry/path.d.ts +2 -0
- package/dist/registry/path.js +11 -1
- package/dist/registry/publish/package.js +8 -0
- package/dist/registry/transfer.d.ts +4 -0
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/transformers/plugins/ssr-render.js +40 -15
- package/dist/transformers/preview/component/builder.d.ts +1 -9
- package/dist/transformers/preview/component/builder.js +17 -17
- package/dist/transformers/preview/component/css.d.ts +3 -1
- package/dist/transformers/preview/component/css.js +92 -51
- package/dist/transformers/preview/component/html.d.ts +3 -1
- package/dist/transformers/preview/component/html.js +6 -2
- package/dist/transformers/preview/component/javascript.d.ts +2 -1
- package/dist/transformers/preview/component/javascript.js +10 -4
- package/dist/transformers/preview/pattern/html.js +3 -1
- package/dist/transformers/preview/types.d.ts +13 -0
- package/dist/transformers/utils/build.d.ts +24 -0
- package/dist/transformers/utils/build.js +100 -1
- package/dist/transformers/utils/module.js +2 -1
- package/dist/types/config.d.ts +135 -18
- package/dist/utils/path.d.ts +1 -5
- package/dist/utils/path.js +6 -23
- package/docs/api.md +6 -4
- package/docs/cli.md +275 -54
- package/drizzle/0007_add_ai_keys.sql +11 -0
- package/drizzle/0008_add_source_dir.sql +2 -0
- package/drizzle/meta/0007_snapshot.json +2013 -0
- package/drizzle/meta/0008_snapshot.json +2025 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +19 -8
- package/src/ai/README.md +42 -0
- package/src/ai/connections.ts +189 -0
- package/src/ai/define.ts +15 -0
- package/src/ai/types.ts +30 -0
- package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
- package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
- package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
- package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
- package/src/app/components/AiAssistant/dockState.ts +86 -0
- package/src/app/components/AiAssistant/toolLabel.ts +24 -0
- package/src/app/components/AiAssistant/useAiChat.ts +147 -0
- package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
- package/src/app/components/AiAssistant/usePageContext.ts +63 -0
- package/src/app/components/Auth/AccountLayout.tsx +58 -19
- package/src/app/components/Auth/AuthShell.tsx +27 -33
- package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
- package/src/app/components/Auth/installStatus.ts +29 -0
- package/src/app/components/Component/PageSliceResolver.tsx +1 -0
- package/src/app/components/Component/Preview.tsx +383 -189
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Header.tsx +21 -15
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +2 -2
- package/src/app/components/Navigation/SideNav.tsx +1 -15
- package/src/app/components/context/NavProvider.tsx +7 -3
- package/src/app/components/ui/attachment.tsx +50 -0
- package/src/app/components/ui/bubble.tsx +57 -0
- package/src/app/components/ui/button.tsx +3 -1
- package/src/app/components/ui/confirm-dialog.tsx +87 -0
- package/src/app/components/ui/dialog.tsx +7 -2
- package/src/app/components/ui/marker.tsx +56 -0
- package/src/app/components/ui/sidebar.tsx +3 -1
- package/src/app/components/util/index.ts +3 -12
- package/src/app/css/index.css +64 -0
- package/src/app/lib/ai/auth.ts +33 -0
- package/src/app/lib/ai/context.ts +99 -0
- package/src/app/lib/ai/model.ts +79 -0
- package/src/app/lib/ai/prompt.ts +36 -0
- package/src/app/lib/ai/resolve.ts +97 -0
- package/src/app/lib/ai/tools.ts +120 -0
- package/src/app/lib/api/methods.ts +13 -0
- package/src/app/lib/asset-storage.ts +35 -36
- package/src/app/lib/auth/api.ts +4 -12
- package/src/app/lib/auth/email.ts +96 -23
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +47 -21
- package/src/app/lib/mcp/components.ts +523 -0
- package/src/app/lib/mcp/page-url.ts +24 -0
- package/src/app/lib/mcp/pages.ts +84 -0
- package/src/app/lib/mcp/result.ts +33 -0
- package/src/app/lib/mcp/server.ts +39 -318
- package/src/app/lib/mcp/tokens.ts +334 -0
- package/src/app/lib/mcp/tool.ts +19 -0
- package/src/app/lib/registry-api/asset-transfer.ts +119 -11
- package/src/app/lib/registry-api/errors.ts +2 -0
- package/src/app/lib/registry-api/handler.ts +7 -1
- package/src/app/lib/registry-api/transfer.ts +28 -6
- package/src/app/lib/server-module.ts +34 -0
- package/src/app/lib/utils.ts +20 -0
- package/src/app/next.config.mjs +26 -24
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/_app.tsx +6 -2
- package/src/app/pages/_document.tsx +6 -0
- package/src/app/pages/account/ai.tsx +183 -0
- package/src/app/pages/account/index.tsx +68 -68
- package/src/app/pages/account/tokens.tsx +37 -7
- package/src/app/pages/account/users.tsx +75 -15
- package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
- package/src/app/pages/api/account/ai/keys/index.ts +20 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
- package/src/app/pages/api/admin/users/index.ts +2 -2
- package/src/app/pages/api/ai/chat.ts +115 -0
- package/src/app/pages/api/ai/connections.ts +23 -0
- package/src/app/pages/api/auth/request-reset.ts +14 -16
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/install.ts +3 -3
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +10 -5
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/install.tsx +7 -2
- package/src/app/pages/login.tsx +32 -27
- package/src/app/pages/reset-password.tsx +21 -20
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/README.md +2 -1
- package/src/app-builder/build.ts +218 -73
- package/src/app-builder/client-config.ts +3 -2
- package/src/app-builder/paths.ts +3 -22
- package/src/app-builder/static-export.ts +1 -1
- package/src/app-builder/vercel-output.ts +5 -4
- package/src/cache/README.md +4 -1
- package/src/cache/build-cache.ts +128 -43
- package/src/cache/build-inputs.ts +28 -0
- package/src/cache/index.ts +4 -0
- package/src/catalog/README.md +3 -0
- package/src/catalog/normalize.ts +2 -0
- package/src/catalog/previews.ts +6 -3
- package/src/catalog/types.ts +8 -0
- package/src/cli/create.ts +1 -2
- package/src/commands/build/index.ts +1 -1
- package/src/config/README.md +10 -9
- package/src/config/defaults.ts +7 -4
- package/src/config/from-env.ts +33 -0
- package/src/config/helpers.ts +1 -3
- package/src/config/index.ts +0 -3
- package/src/config/loader.ts +27 -3
- package/src/config/resolve-env.ts +28 -15
- package/src/config/runtime.ts +10 -5
- package/src/handlebars.ts +8 -2
- package/src/index.ts +33 -29
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/pipeline/README.md +1 -1
- package/src/pipeline/figma.ts +98 -49
- package/src/pipeline/styles.ts +1 -1
- package/src/pipeline/validation.ts +3 -3
- package/src/providers.ts +18 -0
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +38 -37
- package/src/registry/asset-storage/types.ts +39 -17
- package/src/registry/auth/ai-keys.ts +62 -0
- package/src/registry/auth/crypto.ts +63 -1
- package/src/registry/auth/index.ts +11 -1
- package/src/registry/checkout/index.ts +60 -138
- package/src/registry/client.ts +60 -20
- package/src/registry/db/driver.ts +2 -2
- package/src/registry/db/schema.ts +49 -22
- package/src/registry/email/define.ts +15 -0
- package/src/registry/email/providers/smtp.ts +39 -0
- package/src/registry/email/resolve.ts +94 -0
- package/src/registry/email/types.ts +28 -0
- package/src/registry/path.ts +11 -0
- package/src/registry/publish/package.ts +9 -0
- package/src/registry/transfer.ts +4 -0
- package/src/store/registry.ts +7 -7
- package/src/transformers/plugins/ssr-render.ts +53 -16
- package/src/transformers/preview/component/builder.ts +19 -25
- package/src/transformers/preview/component/css.ts +101 -53
- package/src/transformers/preview/component/html.ts +6 -2
- package/src/transformers/preview/component/javascript.ts +14 -4
- package/src/transformers/preview/pattern/html.ts +3 -1
- package/src/transformers/preview/types.ts +14 -0
- package/src/transformers/utils/build.ts +93 -0
- package/src/transformers/utils/module.ts +4 -2
- package/src/types/config.ts +148 -20
- package/src/utils/README.md +1 -1
- package/src/utils/path.ts +6 -29
- package/templates/default/handoff.config.js.tpl +10 -2
- package/templates/default/handoff.config.ts.tpl +10 -2
- package/templates/default/handoff.config.with-examples.js.tpl +10 -2
- package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
- package/templates/default/package.json.tpl +2 -2
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/config/validator.d.ts +0 -9
- package/dist/config/validator.js +0 -24
- package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
- package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/lib/mcp/shape.ts +0 -596
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/config/validator.ts +0 -22
- package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* into the local workspace in standard authoring form. The local **declaration is synthesized
|
|
7
7
|
* locally** in the configured `runtime.workspace.declarationFormat` — declarations are never read
|
|
8
8
|
* from the registry (they are workspace-only). Identity is matched by stable `id`, so a checked-out
|
|
9
|
-
* entity maps to the correct logical component/pattern and can be re-authored and re-published.
|
|
9
|
+
* entity maps to the correct logical component/pattern and can be re-authored and re-published. A
|
|
10
|
+
* new entity goes to the directory it was published from (`sourceDir`).
|
|
10
11
|
*
|
|
11
12
|
* Available only from a connected workspace (`runtime.mode: workspace` + a configured
|
|
12
13
|
* `registryConnection`). Overwriting existing local files is explicit: a `--force` flag or an
|
|
@@ -30,14 +31,14 @@ import {
|
|
|
30
31
|
type RendererKind,
|
|
31
32
|
type SourceFormat,
|
|
32
33
|
} from '../../catalog/renderers';
|
|
33
|
-
import { addToCatalog, isIncluded } from '../../config/catalog-include';
|
|
34
34
|
import { isComponentDirectory, resolveComponentDeclaration } from '../../config/runtime';
|
|
35
35
|
import Handoff from '../../index';
|
|
36
36
|
import type { DeclarationFormat } from '../../types/config';
|
|
37
37
|
import { Logger } from '../../utils/logger';
|
|
38
|
+
import { arePathsEqual } from '../../utils/path';
|
|
38
39
|
import { createRegistryClient, type RegistryClient, RegistryClientError } from '../client';
|
|
39
40
|
import { loginCommandHint, resolveAuthenticatedRegistryConnection, unusedLoginNote } from '../connection';
|
|
40
|
-
import { isSafePathSegment, isSafeRelativePath, resolvePathWithin } from '../path';
|
|
41
|
+
import { isSafePathSegment, isSafeRelativePath, isWorkspaceRelativePath, resolvePathWithin } from '../path';
|
|
41
42
|
import { quoteIds, selectIds, splitCatalogIds } from '../selection';
|
|
42
43
|
import type { CheckoutPayload, TransferEntityKind, TransferFile } from '../transfer';
|
|
43
44
|
|
|
@@ -52,12 +53,7 @@ export class CheckoutError extends Error {
|
|
|
52
53
|
/** Default authoring format synthesized when no existing file or configured preference applies. */
|
|
53
54
|
const DEFAULT_DECLARATION_FORMAT: DeclarationFormat = 'ts';
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
const DEFAULT_ENTITY_DIR: Record<TransferEntityKind, string> = {
|
|
57
|
-
component: 'components',
|
|
58
|
-
pattern: 'patterns',
|
|
59
|
-
page: 'pages',
|
|
60
|
-
};
|
|
56
|
+
const PAGES_DIR = 'pages';
|
|
61
57
|
|
|
62
58
|
/** Source-file extensions stripped to form a module specifier for a React component import. */
|
|
63
59
|
const COMPONENT_EXTENSION = /\.(tsx|jsx|ts|js|cjs|mjs)$/i;
|
|
@@ -108,88 +104,48 @@ const describeFetchFailure = (error: RegistryClientError, kind: TransferEntityKi
|
|
|
108
104
|
}
|
|
109
105
|
};
|
|
110
106
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const promptForCollectionRoot = async (handoff: Handoff, kind: TransferEntityKind, roots: string[]): Promise<string> => {
|
|
117
|
-
const relative = (root: string) => path.relative(handoff.workingPath, root) || '.';
|
|
118
|
-
if (handoff.force) {
|
|
119
|
-
throw new CheckoutError(
|
|
120
|
-
`Cannot determine where to checkout the ${kind}: catalog.include declares directories under different ` +
|
|
121
|
-
`parents (${roots.map(relative).join(', ')}), and none is named "${DEFAULT_ENTITY_DIR[kind]}". Declare a single ` +
|
|
122
|
-
`collection directory in handoff.config, or run checkout without --force to choose interactively.`
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
const choice = await p.select({
|
|
126
|
-
message: `Where should the new ${kind} be checked out?`,
|
|
127
|
-
options: roots.map((root) => ({ value: root, label: relative(root) })),
|
|
128
|
-
initialValue: roots[0],
|
|
107
|
+
/** `dir` is a `catalog.include` entry, or a direct child of a collection entry. */
|
|
108
|
+
const isInCatalog = (handoff: Handoff, dir: string): boolean =>
|
|
109
|
+
(handoff.config?.catalog?.include ?? []).some((entry) => {
|
|
110
|
+
const included = path.resolve(handoff.workingPath, entry);
|
|
111
|
+
return arePathsEqual(included, dir) || (!isComponentDirectory(included) && arePathsEqual(included, path.dirname(dir)));
|
|
129
112
|
});
|
|
130
|
-
if (p.isCancel(choice)) {
|
|
131
|
-
throw new CheckoutError('Checkout cancelled; no target directory chosen.');
|
|
132
|
-
}
|
|
133
|
-
return choice as string;
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Resolve the collection directory a new entity is cloned into from the configured
|
|
138
|
-
* `catalog.include`. Each entry is either a collection directory (used as-is) or a
|
|
139
|
-
* single declared entity directory, in which case its parent is the collection root so the new
|
|
140
|
-
* entity lands as a sibling rather than nested inside. Different parents are settled by the
|
|
141
|
-
* conventional directory name for the kind, and only then by asking the user. With nothing
|
|
142
|
-
* configured we fall back to the default subdir.
|
|
143
|
-
*/
|
|
144
|
-
const resolveCollectionRoot = async (handoff: Handoff, kind: TransferEntityKind): Promise<string> => {
|
|
145
|
-
const configuredRoots = handoff.config?.catalog?.include;
|
|
146
|
-
if (!configuredRoots?.length) {
|
|
147
|
-
return path.resolve(handoff.workingPath, DEFAULT_ENTITY_DIR[kind]);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const roots = [
|
|
151
|
-
...new Set(
|
|
152
|
-
configuredRoots.map((entry) => {
|
|
153
|
-
const resolved = path.resolve(handoff.workingPath, entry);
|
|
154
|
-
return isComponentDirectory(resolved) ? path.dirname(resolved) : resolved;
|
|
155
|
-
})
|
|
156
|
-
),
|
|
157
|
-
];
|
|
158
|
-
|
|
159
|
-
if (roots.length === 1) {
|
|
160
|
-
return roots[0];
|
|
161
|
-
}
|
|
162
113
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return promptForCollectionRoot(handoff, kind, roots);
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Resolve the local directory the entity is written into. An already-checked-out or locally-declared
|
|
176
|
-
* entity keeps its existing source directory (matched by stable `id`); otherwise it lands as a
|
|
177
|
-
* sibling under its configured collection root (see {@link resolveCollectionRoot}).
|
|
178
|
-
*/
|
|
179
|
-
const resolveTargetDir = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<string> => {
|
|
114
|
+
/** A locally declared entity keeps its directory. A new one goes to the directory it was published from. */
|
|
115
|
+
const resolveTargetDir = async (
|
|
116
|
+
handoff: Handoff,
|
|
117
|
+
kind: TransferEntityKind,
|
|
118
|
+
id: string,
|
|
119
|
+
sourceDir: string | undefined
|
|
120
|
+
): Promise<{ dir: string; isInCatalog: boolean }> => {
|
|
180
121
|
const store = kind === 'component' ? handoff.store.components : handoff.store.patterns;
|
|
181
122
|
const existing = await store.get(id);
|
|
182
123
|
const existingDir = (existing as { path?: string } | null)?.path;
|
|
183
124
|
if (existingDir) {
|
|
184
|
-
return existingDir;
|
|
125
|
+
return { dir: existingDir, isInCatalog: true };
|
|
185
126
|
}
|
|
186
127
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
128
|
+
if (!isWorkspaceRelativePath(sourceDir)) {
|
|
129
|
+
throw new CheckoutError(`The registry has no valid source directory for ${kind} "${id}". Republish it, then checkout again.`);
|
|
130
|
+
}
|
|
131
|
+
const dir = path.resolve(handoff.workingPath, sourceDir);
|
|
132
|
+
return { dir, isInCatalog: isInCatalog(handoff, dir) };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** `--force` fails here: it skips overwrite prompts, not approval of a location the registry chose. */
|
|
136
|
+
const confirmLocation = async (handoff: Handoff, kind: TransferEntityKind, id: string, dir: string): Promise<boolean> => {
|
|
137
|
+
const relative = path.relative(handoff.workingPath, dir) || '.';
|
|
138
|
+
if (handoff.force) {
|
|
139
|
+
throw new CheckoutError(
|
|
140
|
+
`The registry places ${kind} "${id}" at ${relative}, which is outside catalog.include. ` +
|
|
141
|
+
`Add "${relative}" or its parent directory to catalog.include, or run checkout without --force to accept the location.`
|
|
142
|
+
);
|
|
191
143
|
}
|
|
192
|
-
|
|
144
|
+
const proceed = await p.confirm({
|
|
145
|
+
message: `The registry places ${kind} "${id}" at ${relative}, which is outside catalog.include. Write it there?`,
|
|
146
|
+
initialValue: false,
|
|
147
|
+
});
|
|
148
|
+
return !p.isCancel(proceed) && proceed === true;
|
|
193
149
|
};
|
|
194
150
|
|
|
195
151
|
/** Read the extension of an existing local declaration in `dir`, if any maps to a known format. */
|
|
@@ -761,7 +717,7 @@ export const reportPlannedWrites = (handoff: Handoff, label: string, targets: st
|
|
|
761
717
|
* markdown file is itself the authored source — so the page round-trips byte-for-byte.
|
|
762
718
|
*/
|
|
763
719
|
const checkoutPage = async (handoff: Handoff, id: string, payload: CheckoutPayload): Promise<void> => {
|
|
764
|
-
const pagesRoot = path.resolve(handoff.workingPath,
|
|
720
|
+
const pagesRoot = path.resolve(handoff.workingPath, PAGES_DIR);
|
|
765
721
|
const targets = payload.files.map((file) => {
|
|
766
722
|
const target = resolvePathWithin(pagesRoot, file.path);
|
|
767
723
|
if (!target) {
|
|
@@ -799,7 +755,7 @@ const checkoutSingle = async (
|
|
|
799
755
|
id: string,
|
|
800
756
|
client: RegistryClient,
|
|
801
757
|
registryUrl: string
|
|
802
|
-
): Promise<
|
|
758
|
+
): Promise<void> => {
|
|
803
759
|
const validId = kind === 'page' ? isSafeRelativePath(id) : isSafePathSegment(id);
|
|
804
760
|
if (!validId) {
|
|
805
761
|
throw new CheckoutError(`Cannot checkout ${kind} with unsafe id "${id}".`);
|
|
@@ -816,14 +772,15 @@ const checkoutSingle = async (
|
|
|
816
772
|
throw error;
|
|
817
773
|
}
|
|
818
774
|
|
|
819
|
-
// Pages round-trip as a single verbatim `.md` with no declaration synthesis, and aren't
|
|
820
|
-
// listed in `catalog.include`, so there's nothing to register.
|
|
821
775
|
if (kind === 'page') {
|
|
822
776
|
await checkoutPage(handoff, id, payload);
|
|
823
|
-
return
|
|
777
|
+
return;
|
|
824
778
|
}
|
|
825
779
|
|
|
826
|
-
const
|
|
780
|
+
const target = await resolveTargetDir(handoff, kind, id, payload.sourceDir);
|
|
781
|
+
const targetDir = target.dir;
|
|
782
|
+
const relativeDir = path.relative(handoff.workingPath, targetDir) || '.';
|
|
783
|
+
const outsideCatalogWarning = `Add "${relativeDir}" to catalog.include, or the build will not load ${kind} "${id}".`;
|
|
827
784
|
const format = resolveDeclarationFormat(handoff, targetDir);
|
|
828
785
|
const declarationFileName = resolveDeclarationFileName(targetDir, id, format);
|
|
829
786
|
const declarationPath = path.join(targetDir, declarationFileName);
|
|
@@ -840,13 +797,19 @@ const checkoutSingle = async (
|
|
|
840
797
|
const plannedWrites = [...sourceTargets.map((entry) => entry.absolutePath), declarationPath];
|
|
841
798
|
const conflicts = plannedWrites.filter((file) => fs.existsSync(file));
|
|
842
799
|
if (handoff.dryRun) {
|
|
843
|
-
// Returning null also skips `registerCheckedOut`, so a dry run never edits handoff.config either.
|
|
844
800
|
reportPlannedWrites(handoff, `${kind} "${id}"`, plannedWrites, conflicts);
|
|
845
|
-
|
|
801
|
+
if (!target.isInCatalog) {
|
|
802
|
+
Logger.warn(`${relativeDir} is outside catalog.include. ${outsideCatalogWarning}`);
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (!target.isInCatalog && !(await confirmLocation(handoff, kind, id, targetDir))) {
|
|
807
|
+
Logger.warn(`Checkout of ${kind} "${id}" cancelled; no files were written.`);
|
|
808
|
+
return;
|
|
846
809
|
}
|
|
847
810
|
if (!(await confirmOverwrite(handoff, conflicts))) {
|
|
848
811
|
Logger.warn(`Checkout of ${kind} "${id}" cancelled; no files were written.`);
|
|
849
|
-
return
|
|
812
|
+
return;
|
|
850
813
|
}
|
|
851
814
|
|
|
852
815
|
await fs.ensureDir(targetDir);
|
|
@@ -858,41 +821,10 @@ const checkoutSingle = async (
|
|
|
858
821
|
await fs.writeFile(declarationPath, declaration, 'utf8');
|
|
859
822
|
written.push(declarationPath);
|
|
860
823
|
|
|
861
|
-
Logger.success(
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
);
|
|
865
|
-
return targetDir;
|
|
866
|
-
};
|
|
867
|
-
|
|
868
|
-
/**
|
|
869
|
-
* Declare freshly checked-out entities in `catalog.include` so the workspace build
|
|
870
|
-
* picks them up. Ones already covered by a collection directory load on their own and are left
|
|
871
|
-
* alone; the rest are added to the config automatically. If the config can't be edited (a computed
|
|
872
|
-
* or unusual `include` array), we print the paths for the user to add so nothing is silently orphaned.
|
|
873
|
-
*/
|
|
874
|
-
const registerCheckedOut = async (handoff: Handoff, kind: TransferEntityKind, targetDirs: string[]): Promise<void> => {
|
|
875
|
-
if (kind === 'page') {
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
const kindLabel = kind === 'component' ? 'components' : 'patterns';
|
|
879
|
-
const unlisted = targetDirs.filter((dir) => !isIncluded(handoff, dir));
|
|
880
|
-
if (unlisted.length === 0) {
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
const result = await addToCatalog(handoff, unlisted);
|
|
885
|
-
if (result.status === 'added') {
|
|
886
|
-
const where = result.configPath
|
|
887
|
-
? path.relative(handoff.workingPath, result.configPath) || path.basename(result.configPath)
|
|
888
|
-
: 'handoff.config';
|
|
889
|
-
Logger.success(`Updated ${where} with ${result.added.length} ${kindLabel} path(s).`);
|
|
890
|
-
return;
|
|
824
|
+
Logger.success(`Checked out ${kind} "${id}" into ${relativeDir} (${payload.files.length} source file(s) + ${declarationFileName}).`);
|
|
825
|
+
if (!target.isInCatalog) {
|
|
826
|
+
Logger.warn(outsideCatalogWarning);
|
|
891
827
|
}
|
|
892
|
-
|
|
893
|
-
const where = result.configPath ? path.relative(handoff.workingPath, result.configPath) : 'handoff.config';
|
|
894
|
-
Logger.warn(`Could not update ${where} automatically. Add these to catalog.include manually:`);
|
|
895
|
-
result.pending.forEach((rel) => Logger.warn(` - ${rel}`));
|
|
896
828
|
};
|
|
897
829
|
|
|
898
830
|
/**
|
|
@@ -902,10 +834,7 @@ const registerCheckedOut = async (handoff: Handoff, kind: TransferEntityKind, ta
|
|
|
902
834
|
export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
|
|
903
835
|
const connection = await resolveConnectionOrThrow(handoff);
|
|
904
836
|
const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
|
|
905
|
-
|
|
906
|
-
if (targetDir) {
|
|
907
|
-
await registerCheckedOut(handoff, kind, [targetDir]);
|
|
908
|
-
}
|
|
837
|
+
await checkoutSingle(handoff, kind, id, client, connection.url);
|
|
909
838
|
};
|
|
910
839
|
|
|
911
840
|
/** List the ids the registry publishes for one kind, naming the registry in a listing failure. */
|
|
@@ -922,7 +851,7 @@ const listPublishedIds = async (kind: TransferEntityKind, client: RegistryClient
|
|
|
922
851
|
};
|
|
923
852
|
|
|
924
853
|
/**
|
|
925
|
-
* Check out the given ids of one kind
|
|
854
|
+
* Check out the given ids of one kind. The caller selects the ids, because a
|
|
926
855
|
* catalog run resolves them across both entity lanes before any of them is written.
|
|
927
856
|
*
|
|
928
857
|
* A per-entity failure is collected and never aborts the rest. Failures are reported here, and the
|
|
@@ -937,22 +866,15 @@ const checkoutSelected = async (
|
|
|
937
866
|
): Promise<number> => {
|
|
938
867
|
let checkedOut = 0;
|
|
939
868
|
const failed: { id: string; message: string }[] = [];
|
|
940
|
-
const targetDirs: string[] = [];
|
|
941
869
|
for (const id of targets) {
|
|
942
870
|
try {
|
|
943
|
-
|
|
944
|
-
if (targetDir) {
|
|
945
|
-
targetDirs.push(targetDir);
|
|
946
|
-
}
|
|
871
|
+
await checkoutSingle(handoff, kind, id, client, registryUrl);
|
|
947
872
|
checkedOut += 1;
|
|
948
873
|
} catch (error) {
|
|
949
874
|
failed.push({ id, message: error instanceof Error ? error.message : String(error) });
|
|
950
875
|
}
|
|
951
876
|
}
|
|
952
877
|
|
|
953
|
-
// Register everything checked out in one pass, so the config is edited (and confirmed) once.
|
|
954
|
-
await registerCheckedOut(handoff, kind, targetDirs);
|
|
955
|
-
|
|
956
878
|
Logger.success(
|
|
957
879
|
`${kind[0].toUpperCase()}${kind.slice(1)}s checkout complete — ${checkedOut} ${handoff.dryRun ? 'would be checked out' : 'checked out'}` +
|
|
958
880
|
`${failed.length ? `, ${failed.length} failed` : ''}.`
|
package/src/registry/client.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { CheckoutPayload, EntitySummary, TransferEntityKind, TransferPackage } from './transfer';
|
|
12
12
|
import type { TokenSetCheckoutPayload, TokenSetSummary, TokenSetTransferPackage } from './tokens/transfer';
|
|
13
13
|
import type { AssetCollectionCheckoutPayload, AssetCollectionSummary, AssetCollectionTransferPackage } from './assets/transfer';
|
|
14
|
+
import type { AssetUpload } from './asset-storage/types';
|
|
14
15
|
|
|
15
16
|
/** Encode a token set id for the catch-all transfer route, preserving `/` between segments. */
|
|
16
17
|
const encodeSetIdPath = (id: string): string =>
|
|
@@ -139,6 +140,30 @@ export const createRegistryClient = ({ baseUrl, accessToken }: RegistryClientOpt
|
|
|
139
140
|
return envelope;
|
|
140
141
|
};
|
|
141
142
|
|
|
143
|
+
const uploadAssetBlobThroughRegistry = async (blobPath: string, contentType: string, bytes: Buffer): Promise<void> => {
|
|
144
|
+
const url = `${root}${blobPath}`;
|
|
145
|
+
// Send a Blob, not a Buffer/Uint8Array: under Next `trailingSlash: true` this PUT is
|
|
146
|
+
// 308-redirected and undici must re-send the body; a typed-array body's ArrayBuffer is detached
|
|
147
|
+
// after the first send ("slice on a detached ArrayBuffer"), but a Blob is re-readable.
|
|
148
|
+
const body = new Blob([new Uint8Array(bytes)] as unknown as BlobPart[]);
|
|
149
|
+
const response = await fetchRegistry(url, {
|
|
150
|
+
method: 'PUT',
|
|
151
|
+
headers: {
|
|
152
|
+
'Content-Type': contentType || 'application/octet-stream',
|
|
153
|
+
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
154
|
+
},
|
|
155
|
+
body,
|
|
156
|
+
});
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
let code: string | undefined;
|
|
159
|
+
let message = `Blob upload failed (${response.status} ${response.statusText}).`;
|
|
160
|
+
const envelope = await readErrorEnvelope(response);
|
|
161
|
+
code = envelope?.error?.code;
|
|
162
|
+
message = envelope?.error?.message || message;
|
|
163
|
+
throw new RegistryClientError(message, { status: response.status, code });
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
142
167
|
return {
|
|
143
168
|
/** Publish (`PUT`) an entity's package to the registry transfer endpoint. */
|
|
144
169
|
publish(kind: TransferEntityKind, id: string, pkg: TransferPackage): Promise<RegistryEnvelope> {
|
|
@@ -187,29 +212,44 @@ export const createRegistryClient = ({ baseUrl, accessToken }: RegistryClientOpt
|
|
|
187
212
|
const envelope = await request<{ missing: string[] }>('POST', '/api/registry/transfer/assets/blobs/have', { hashes });
|
|
188
213
|
return envelope.data?.missing ?? [];
|
|
189
214
|
},
|
|
190
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Upload one content-addressed blob. Sends it directly to storage when the registry returns a
|
|
217
|
+
* signed URL, otherwise through the registry. Idempotent: re-uploading an existing hash no-ops.
|
|
218
|
+
*/
|
|
191
219
|
async uploadAssetBlob(hash: string, contentType: string, bytes: Buffer): Promise<void> {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
220
|
+
const blobPath = `/api/registry/transfer/assets/blobs/${encodeURIComponent(hash)}`;
|
|
221
|
+
const size = bytes.length;
|
|
222
|
+
const envelope = await request<{ upload: AssetUpload | null; stored?: boolean }>('POST', `${blobPath}/upload`, { size, contentType });
|
|
223
|
+
if (envelope.data?.stored) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const upload = envelope.data?.upload;
|
|
227
|
+
if (!upload) {
|
|
228
|
+
await uploadAssetBlobThroughRegistry(blobPath, contentType, bytes);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Name only the origin in errors: the signed query string must not reach logs.
|
|
233
|
+
const storage = new URL(upload.url).origin;
|
|
234
|
+
let response: Response;
|
|
235
|
+
try {
|
|
236
|
+
response = await fetch(upload.url, {
|
|
237
|
+
method: 'PUT',
|
|
238
|
+
headers: upload.headers,
|
|
239
|
+
body: new Blob([new Uint8Array(bytes)] as unknown as BlobPart[]),
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const cause = (error as { cause?: { code?: string; message?: string } } | undefined)?.cause;
|
|
243
|
+
throw new RegistryClientError(`Could not reach asset storage at ${storage}: ${cause?.code || cause?.message || 'network error'}.`);
|
|
244
|
+
}
|
|
205
245
|
if (!response.ok) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
throw new RegistryClientError(message, { status: response.status, code });
|
|
246
|
+
const detail = (await response.text().catch(() => '')).slice(0, 300);
|
|
247
|
+
throw new RegistryClientError(
|
|
248
|
+
`Asset storage at ${storage} rejected the upload of blob "${hash}" (${response.status} ${response.statusText})${detail ? `: ${detail}` : ''}.`,
|
|
249
|
+
{ status: response.status }
|
|
250
|
+
);
|
|
212
251
|
}
|
|
252
|
+
await request('POST', `${blobPath}/complete`, { storageRef: upload.storageRef, size, contentType });
|
|
213
253
|
},
|
|
214
254
|
/** Download one blob's raw bytes by hash (follows any provider redirect). */
|
|
215
255
|
async downloadAssetBlob(hash: string): Promise<Buffer> {
|
|
@@ -36,7 +36,7 @@ export interface ResolvedRegistryDatabase {
|
|
|
36
36
|
|
|
37
37
|
/** Resolve the env-var name holding the database URL (defaults to `DATABASE_URL`). */
|
|
38
38
|
export const resolveDatabaseUrlEnv = (config: ResolvedConfig | null | undefined): string => {
|
|
39
|
-
const configured = config?.runtime?.registry?.
|
|
39
|
+
const configured = config?.runtime?.registry?.database?.url?.$env?.trim();
|
|
40
40
|
return configured || DEFAULT_DATABASE_URL_ENV;
|
|
41
41
|
};
|
|
42
42
|
|
|
@@ -58,7 +58,7 @@ export const resolveRegistryDatabase = (config: ResolvedConfig | null | undefine
|
|
|
58
58
|
if (!connectionString) {
|
|
59
59
|
throw new Error(
|
|
60
60
|
`Registry database URL is not configured. Set the "${databaseUrlEnv}" environment variable to a ` +
|
|
61
|
-
`PostgreSQL connection string (or change "runtime.registry.
|
|
61
|
+
`PostgreSQL connection string (or change "runtime.registry.database.url" to point at the env var you use).`
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -30,7 +30,7 @@ import type { TokenSetKind } from '../tokens/sets';
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* PostgreSQL `bytea` column. Drizzle pg-core has no built-in binary type, so declare it once here.
|
|
33
|
-
* Reads/writes are Node `Buffer`s. The default asset storage
|
|
33
|
+
* Reads/writes are Node `Buffer`s. The default asset storage provider keeps small/ordinary asset
|
|
34
34
|
* bytes inline in this column; larger assets are offloaded to an object-storage `storageRef` instead.
|
|
35
35
|
*/
|
|
36
36
|
const bytea = customType<{ data: Buffer; driverData: Buffer }>({
|
|
@@ -39,8 +39,8 @@ const bytea = customType<{ data: Buffer; driverData: Buffer }>({
|
|
|
39
39
|
},
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
/** Where an asset blob's bytes
|
|
43
|
-
export type AssetStorageProvider = 'database' | '
|
|
42
|
+
/** Where an asset blob's bytes live: inline in the database (`database`) or in a custom provider (`custom`). */
|
|
43
|
+
export type AssetStorageProvider = 'database' | 'custom';
|
|
44
44
|
import type { ComponentListObject, PageListObject, PatternComponentEntry, PatternListObject } from '../../transformers/preview/types';
|
|
45
45
|
|
|
46
46
|
/** Registry account role. Administrators may issue write-scoped credentials. */
|
|
@@ -112,6 +112,8 @@ export const components = pgTable(
|
|
|
112
112
|
sourceFormat: text('source_format'),
|
|
113
113
|
tags: jsonb('tags').$type<string[]>(),
|
|
114
114
|
categories: jsonb('categories').$type<string[]>(),
|
|
115
|
+
/** Workspace-relative directory the component was published from. */
|
|
116
|
+
sourceDir: text('source_dir'),
|
|
115
117
|
/** Full normalized component record. */
|
|
116
118
|
record: jsonb('record').$type<ComponentListObject>().notNull(),
|
|
117
119
|
/** Registry-only review/catalog metadata (management-API allowlist; never a render input). */
|
|
@@ -165,6 +167,8 @@ export const patterns = pgTable(
|
|
|
165
167
|
tags: jsonb('tags').$type<string[]>(),
|
|
166
168
|
/** Pattern component references. */
|
|
167
169
|
components: jsonb('components').$type<PatternComponentEntry[]>(),
|
|
170
|
+
/** Workspace-relative directory the pattern was published from. */
|
|
171
|
+
sourceDir: text('source_dir'),
|
|
168
172
|
/** Full normalized pattern record. */
|
|
169
173
|
record: jsonb('record').$type<PatternListObject>().notNull(),
|
|
170
174
|
/** Registry-only review/catalog metadata (management-API allowlist; never a render input). */
|
|
@@ -378,23 +382,20 @@ export const tokenArtifacts = pgTable(
|
|
|
378
382
|
* collection is its current manifest. Publish replaces those rows atomically, so readers always see a
|
|
379
383
|
* complete manifest.
|
|
380
384
|
*/
|
|
381
|
-
export const assetCollections = pgTable(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
396
|
-
}
|
|
397
|
-
);
|
|
385
|
+
export const assetCollections = pgTable('asset_collections', {
|
|
386
|
+
/** Stable collection id (`icons`|`logos`|`fonts`). Join key across stores. */
|
|
387
|
+
collection: text('collection').primaryKey(),
|
|
388
|
+
/** Deterministic content hash over the collection's `(path, contentHash)` manifest (skip-unchanged). */
|
|
389
|
+
sourceHash: text('source_hash'),
|
|
390
|
+
/** Build/publish status for the collection. */
|
|
391
|
+
status: text('status').$type<RegistryBuildStatus>(),
|
|
392
|
+
builtAt: timestamp('built_at', { withTimezone: true }),
|
|
393
|
+
builderVersion: text('builder_version'),
|
|
394
|
+
/** Registry-only review/catalog metadata (parity with other entities; management-API only). */
|
|
395
|
+
metadata: jsonb('metadata').$type<RegistryReviewMetadata>(),
|
|
396
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
397
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
398
|
+
});
|
|
398
399
|
|
|
399
400
|
/**
|
|
400
401
|
* Logical asset metadata - the collection manifest. One row per asset (icon/logo SVG, sprite,
|
|
@@ -431,13 +432,13 @@ export const assets = pgTable(
|
|
|
431
432
|
/**
|
|
432
433
|
* Content-addressed binary blob store, shared across assets/collections (the `hash` is the blob
|
|
433
434
|
* identity, so an unchanged file re-references the same row). Exactly one location is active per row:
|
|
434
|
-
* inline `content` (bytea, default
|
|
435
|
+
* inline `content` (bytea, default `database` provider) or an external `storageRef` resolved through the
|
|
435
436
|
* recorded `storageProvider`. Both nullable so DB- and object-backed blobs coexist.
|
|
436
437
|
*/
|
|
437
438
|
export const assetBlobs = pgTable('asset_blobs', {
|
|
438
439
|
/** SHA-256 of the blob bytes (hex). */
|
|
439
440
|
hash: text('hash').primaryKey(),
|
|
440
|
-
/** Which storage backed this blob when it was written (`database
|
|
441
|
+
/** Which storage backed this blob when it was written (`database`|custom id). */
|
|
441
442
|
storageProvider: text('storage_provider').$type<AssetStorageProvider>().notNull(),
|
|
442
443
|
/** Inline bytes; null when stored externally via `storageRef`. */
|
|
443
444
|
content: bytea('content'),
|
|
@@ -575,6 +576,31 @@ export const registryDeviceAuthorizations = pgTable(
|
|
|
575
576
|
]
|
|
576
577
|
);
|
|
577
578
|
|
|
579
|
+
/**
|
|
580
|
+
* One reader's key for one declared AI connection. Unlike every other credential here the value
|
|
581
|
+
* must be recoverable, because it is sent to the provider on that reader's behalf, so it is
|
|
582
|
+
* encrypted rather than hashed (see `../auth/crypto`). A row whose `connectionId` no longer appears
|
|
583
|
+
* in the config is ignored, not deleted: the connection may come back on the next deploy.
|
|
584
|
+
*/
|
|
585
|
+
export const registryAiKeys = pgTable(
|
|
586
|
+
'registry_ai_keys',
|
|
587
|
+
{
|
|
588
|
+
userId: text('user_id')
|
|
589
|
+
.notNull()
|
|
590
|
+
.references(() => registryUsers.id, { onDelete: 'cascade' }),
|
|
591
|
+
/** Id of the connection the config declares. Never a reader-supplied endpoint. */
|
|
592
|
+
connectionId: text('connection_id').notNull(),
|
|
593
|
+
/** Self-describing AES-256-GCM payload; the deployment secret it is keyed from is never stored. */
|
|
594
|
+
encryptedKey: text('encrypted_key').notNull(),
|
|
595
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
596
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
597
|
+
},
|
|
598
|
+
(table) => [
|
|
599
|
+
primaryKey({ columns: [table.userId, table.connectionId] }),
|
|
600
|
+
check('registry_ai_keys_connection_not_blank', sql`length(btrim(${table.connectionId})) > 0`),
|
|
601
|
+
]
|
|
602
|
+
);
|
|
603
|
+
|
|
578
604
|
/**
|
|
579
605
|
* Fixed-window throttling counters. Callers pass a hash of the identifying value so raw emails and
|
|
580
606
|
* IP addresses are not retained.
|
|
@@ -616,6 +642,7 @@ export const registrySchema = {
|
|
|
616
642
|
registryInstallations,
|
|
617
643
|
registryAuthActionTokens,
|
|
618
644
|
registryAccessTokens,
|
|
645
|
+
registryAiKeys,
|
|
619
646
|
registryDeviceAuthorizations,
|
|
620
647
|
registryAuthRateLimits,
|
|
621
648
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed identity helper for custom email providers (mirrors `defineStorageProvider`): a consumer
|
|
3
|
+
* default-exports its result from a **server-only** module and points `runtime.registry.email.module`
|
|
4
|
+
* at it. It must be its own module (not an inline `defineConfig` function) so the build can trace it
|
|
5
|
+
* into the deployed registry. The default export may be the provider object, or a factory
|
|
6
|
+
* `(context) => EmailProvider` when it needs its options or env.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { EmailProvider, EmailProviderFactory } from './types';
|
|
10
|
+
|
|
11
|
+
export function defineEmailProvider(provider: EmailProvider): EmailProvider;
|
|
12
|
+
export function defineEmailProvider(factory: EmailProviderFactory): EmailProviderFactory;
|
|
13
|
+
export function defineEmailProvider(input: EmailProvider | EmailProviderFactory): EmailProvider | EmailProviderFactory {
|
|
14
|
+
return input;
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in SMTP provider, selected with `runtime.registry.email.provider: "smtp"`.
|
|
3
|
+
*
|
|
4
|
+
* `nodemailer` is loaded lazily so it is only pulled into memory (and traced into the registry
|
|
5
|
+
* bundle) when this provider is selected. Options may come from environment variables, so numbers
|
|
6
|
+
* and booleans can arrive as strings.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { EmailProvider } from '../types';
|
|
10
|
+
|
|
11
|
+
const toNumber = (value: unknown): number | undefined => {
|
|
12
|
+
const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN;
|
|
13
|
+
return Number.isFinite(number) ? number : undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const toBoolean = (value: unknown): boolean | undefined =>
|
|
17
|
+
typeof value === 'boolean' ? value : typeof value === 'string' && value.trim() ? value.trim() === 'true' : undefined;
|
|
18
|
+
|
|
19
|
+
/** Build an SMTP provider from options whose references are already resolved. */
|
|
20
|
+
export const createSmtpProvider = (options: Record<string, unknown>): EmailProvider => {
|
|
21
|
+
const host = typeof options.host === 'string' ? options.host : '';
|
|
22
|
+
const configuredPort = toNumber(options.port);
|
|
23
|
+
const secure = toBoolean(options.secure) ?? (configuredPort === undefined || configuredPort === 465);
|
|
24
|
+
const port = configuredPort ?? (secure ? 465 : 587);
|
|
25
|
+
const user = typeof options.user === 'string' ? options.user : undefined;
|
|
26
|
+
const password = typeof options.password === 'string' ? options.password : undefined;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
async send({ from, to, subject, html, text }) {
|
|
30
|
+
if (!host) throw new Error('The SMTP host is not set.');
|
|
31
|
+
const { createTransport } = await import('nodemailer');
|
|
32
|
+
const transport = createTransport({ host, port, secure, auth: user ? { user, pass: password } : undefined });
|
|
33
|
+
const info = await transport.sendMail({ from, to, subject, html, text });
|
|
34
|
+
if (info.rejected?.length) {
|
|
35
|
+
throw new Error(`The SMTP server rejected the recipient: ${info.response}`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
};
|