handoff-app 2.0.0-9 → 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 +813 -406
- 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/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 +26 -2
- package/dist/config/resolve-env.d.ts +2 -1
- package/dist/config/resolve-env.js +28 -12
- package/dist/config/runtime.js +1 -1
- 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/preview/component/builder.d.ts +1 -9
- package/dist/transformers/preview/component/builder.js +17 -17
- package/dist/transformers/preview/component/css.d.ts +2 -1
- package/dist/transformers/preview/component/css.js +8 -5
- 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/utils/build.d.ts +5 -0
- package/dist/transformers/utils/build.js +21 -4
- package/dist/types/config.d.ts +129 -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/Preview.tsx +5 -2
- 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/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 +25 -3
- package/src/config/resolve-env.ts +28 -15
- package/src/config/runtime.ts +1 -1
- 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/preview/component/builder.ts +19 -25
- package/src/transformers/preview/component/css.ts +9 -4
- package/src/transformers/preview/component/html.ts +6 -2
- package/src/transformers/preview/component/javascript.ts +14 -4
- package/src/transformers/utils/build.ts +16 -3
- package/src/types/config.ts +142 -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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-only asset storage contract for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Server-only asset storage contract for custom {@link defineStorageProvider} object storage modules.
|
|
3
|
+
* The built-in `database` provider is the inline default: bytes live in the `asset_blobs.content`
|
|
4
|
+
* bytea column, read/written directly by the registry, so it needs no module. Object providers
|
|
5
|
+
* record a `storageRef` + provider id so reads resolve back through whichever provider stored the
|
|
6
|
+
* blob.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/** Input for storing one content-addressed blob. */
|
|
10
|
-
export interface
|
|
10
|
+
export interface StorageInput {
|
|
11
11
|
/** SHA-256 of the bytes (hex), the content identity, usable as a stable object key. */
|
|
12
12
|
hash: string;
|
|
13
13
|
bytes: Buffer;
|
|
@@ -19,28 +19,50 @@ export interface AssetStorageInput {
|
|
|
19
19
|
* How a provider returns stored content: raw bytes, a readable stream, or a signed/redirect URL the
|
|
20
20
|
* client can fetch directly (used to avoid routing large payloads through the serverless function).
|
|
21
21
|
*/
|
|
22
|
-
export type
|
|
22
|
+
export type StorageReadResult =
|
|
23
23
|
| { kind: 'bytes'; bytes: Buffer; contentType?: string }
|
|
24
24
|
| { kind: 'stream'; stream: NodeJS.ReadableStream; contentType?: string }
|
|
25
25
|
| { kind: 'redirect'; url: string };
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
export interface
|
|
27
|
+
/** Input for a direct upload of one blob from the CLI to storage. */
|
|
28
|
+
export interface AssetUploadInput {
|
|
29
|
+
/** SHA-256 of the bytes (hex). */
|
|
30
|
+
hash: string;
|
|
31
|
+
size: number;
|
|
32
|
+
contentType: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A signed URL the CLI sends the blob to with `PUT` and the given headers. */
|
|
36
|
+
export interface AssetUpload {
|
|
37
|
+
url: string;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
/** The reference to persist on the blob's `asset_blobs` row after the upload. */
|
|
40
|
+
storageRef: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The pluggable storage contract for object-backed asset providers. */
|
|
44
|
+
export interface StorageProvider {
|
|
29
45
|
/** Store one blob and return the reference to persist on its `asset_blobs` row. */
|
|
30
|
-
put(input:
|
|
46
|
+
put(input: StorageInput): Promise<{ storageRef: string }>;
|
|
47
|
+
/**
|
|
48
|
+
* Return a signed URL so the CLI uploads the blob directly to storage instead of through the
|
|
49
|
+
* registry, which avoids serverless request size limits. The URL must make storage reject bytes
|
|
50
|
+
* that do not match `hash`, for example with a signed SHA-256 checksum header.
|
|
51
|
+
*/
|
|
52
|
+
createUpload?(input: AssetUploadInput): Promise<AssetUpload>;
|
|
31
53
|
/** Resolve stored content by its `storageRef`. */
|
|
32
|
-
get(storageRef: string): Promise<
|
|
54
|
+
get(storageRef: string): Promise<StorageReadResult>;
|
|
33
55
|
/** Delete stored content by its `storageRef`. Should be retryable/idempotent. */
|
|
34
56
|
delete(storageRef: string): Promise<void>;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
/** Context passed to a custom
|
|
38
|
-
export interface
|
|
39
|
-
/**
|
|
59
|
+
/** Context passed to a custom provider factory: its config options and the process env. */
|
|
60
|
+
export interface StorageProviderContext {
|
|
61
|
+
/** `runtime.registry.assetStorage.options`, with each `fromEnv()` reference replaced by its value. */
|
|
40
62
|
options: Record<string, unknown>;
|
|
41
|
-
/** The process environment.
|
|
63
|
+
/** The process environment. */
|
|
42
64
|
env: NodeJS.ProcessEnv;
|
|
43
65
|
}
|
|
44
66
|
|
|
45
|
-
/** A custom
|
|
46
|
-
export type
|
|
67
|
+
/** A custom provider module may export the provider directly or a factory that builds it from its context. */
|
|
68
|
+
export type StorageProviderFactory = (context: StorageProviderContext) => StorageProvider | Promise<StorageProvider>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm';
|
|
2
|
+
import type { RegistryDatabase } from '../db/client';
|
|
3
|
+
import { registryAiKeys } from '../db/schema';
|
|
4
|
+
import { decryptSecret, encryptSecret } from './crypto';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reader-supplied keys for connections the config declares with `credential: 'user'`.
|
|
8
|
+
*
|
|
9
|
+
* The key is write-only across the API boundary: once saved, a read reports only that a key exists.
|
|
10
|
+
* Only the chat route ever decrypts one, and only to send it to the connection's own endpoint.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ids of the connections this reader has a *usable* key for. A row that no longer decrypts — written
|
|
15
|
+
* under a rotated `HANDOFF_AI_KEY_SECRET` — is left out, so the account page and the assistant agree
|
|
16
|
+
* that no key is in place instead of offering a key the provider would never receive.
|
|
17
|
+
*/
|
|
18
|
+
export const listRegistryAiKeyConnections = async (db: RegistryDatabase, userId: string): Promise<string[]> => {
|
|
19
|
+
const rows = await db
|
|
20
|
+
.select({ connectionId: registryAiKeys.connectionId, encryptedKey: registryAiKeys.encryptedKey })
|
|
21
|
+
.from(registryAiKeys)
|
|
22
|
+
.where(eq(registryAiKeys.userId, userId))
|
|
23
|
+
.orderBy(registryAiKeys.connectionId);
|
|
24
|
+
return rows.filter((row) => decryptSecret(row.encryptedKey) !== null).map((row) => row.connectionId);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Save or replace this reader's key for one connection. */
|
|
28
|
+
export const saveRegistryAiKey = async (
|
|
29
|
+
db: RegistryDatabase,
|
|
30
|
+
input: { userId: string; connectionId: string; key: string }
|
|
31
|
+
): Promise<void> => {
|
|
32
|
+
const encryptedKey = encryptSecret(input.key);
|
|
33
|
+
await db
|
|
34
|
+
.insert(registryAiKeys)
|
|
35
|
+
.values({ userId: input.userId, connectionId: input.connectionId, encryptedKey })
|
|
36
|
+
.onConflictDoUpdate({
|
|
37
|
+
target: [registryAiKeys.userId, registryAiKeys.connectionId],
|
|
38
|
+
set: { encryptedKey, updatedAt: new Date() },
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Remove this reader's key for one connection. Returns whether a row was there to remove. */
|
|
43
|
+
export const deleteRegistryAiKey = async (db: RegistryDatabase, input: { userId: string; connectionId: string }): Promise<boolean> => {
|
|
44
|
+
const rows = await db
|
|
45
|
+
.delete(registryAiKeys)
|
|
46
|
+
.where(and(eq(registryAiKeys.userId, input.userId), eq(registryAiKeys.connectionId, input.connectionId)))
|
|
47
|
+
.returning({ connectionId: registryAiKeys.connectionId });
|
|
48
|
+
return rows.length > 0;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read back one reader's key. A row that no longer decrypts reads as absent, so the reader is told
|
|
53
|
+
* to add a key rather than shown a provider error.
|
|
54
|
+
*/
|
|
55
|
+
export const readRegistryAiKey = async (db: RegistryDatabase, input: { userId: string; connectionId: string }): Promise<string | null> => {
|
|
56
|
+
const [row] = await db
|
|
57
|
+
.select({ encryptedKey: registryAiKeys.encryptedKey })
|
|
58
|
+
.from(registryAiKeys)
|
|
59
|
+
.where(and(eq(registryAiKeys.userId, input.userId), eq(registryAiKeys.connectionId, input.connectionId)))
|
|
60
|
+
.limit(1);
|
|
61
|
+
return row ? decryptSecret(row.encryptedKey) : null;
|
|
62
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
export const hashSecret = (secret: string): string => createHash('sha256').update(secret, 'utf8').digest('hex');
|
|
4
4
|
|
|
@@ -11,3 +11,65 @@ export const secretHashMatches = (secret: string, expectedHash: string): boolean
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
export const normalizeEmail = (email: string): string => email.trim().toLowerCase();
|
|
14
|
+
|
|
15
|
+
/** Env var holding the deployment secret that reader-supplied AI keys are encrypted with. */
|
|
16
|
+
export const AI_KEY_SECRET_ENV = 'HANDOFF_AI_KEY_SECRET';
|
|
17
|
+
|
|
18
|
+
/** Shortest secret worth accepting. Matches the guidance the installer gives for `AUTH_SECRET`. */
|
|
19
|
+
const MIN_KEY_SECRET_LENGTH = 32;
|
|
20
|
+
|
|
21
|
+
/** Format marker, so a later algorithm is distinguishable from this one rather than guessed at. */
|
|
22
|
+
const ENCRYPTED_SECRET_VERSION = 'v1';
|
|
23
|
+
|
|
24
|
+
/** Thrown when a key must be encrypted but the deployment secret is missing or too short. */
|
|
25
|
+
export class MissingEncryptionSecretError extends Error {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(
|
|
28
|
+
`Reader-supplied AI keys cannot be stored because ${AI_KEY_SECRET_ENV} is not set. Set it to a ` +
|
|
29
|
+
`random value of at least ${MIN_KEY_SECRET_LENGTH} characters, or declare no connection with ` +
|
|
30
|
+
`"credential: 'user'".`
|
|
31
|
+
);
|
|
32
|
+
this.name = 'MissingEncryptionSecretError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive the record key. A per-record salt means two rows holding the same provider key produce
|
|
38
|
+
* different ciphertexts, and the fixed `info` label keeps this key distinct from anything else the
|
|
39
|
+
* same secret is ever used for.
|
|
40
|
+
*/
|
|
41
|
+
const deriveKey = (salt: Buffer): Buffer => {
|
|
42
|
+
const secret = process.env[AI_KEY_SECRET_ENV]?.trim();
|
|
43
|
+
if (!secret || secret.length < MIN_KEY_SECRET_LENGTH) throw new MissingEncryptionSecretError();
|
|
44
|
+
return Buffer.from(hkdfSync('sha256', Buffer.from(secret, 'utf8'), salt, 'handoff-ai-key', 32));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Encrypt a value that has to be read back, such as a reader's provider key. AES-256-GCM, with the
|
|
49
|
+
* salt, nonce and authentication tag carried in the payload so a row is self-describing.
|
|
50
|
+
*/
|
|
51
|
+
export const encryptSecret = (plaintext: string): string => {
|
|
52
|
+
const salt = randomBytes(16);
|
|
53
|
+
const iv = randomBytes(12);
|
|
54
|
+
const cipher = createCipheriv('aes-256-gcm', deriveKey(salt), iv);
|
|
55
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
56
|
+
const parts = [salt, iv, cipher.getAuthTag(), encrypted].map((part) => part.toString('base64url'));
|
|
57
|
+
return [ENCRYPTED_SECRET_VERSION, ...parts].join('.');
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decrypt a value written by {@link encryptSecret}. Returns `null` for anything that does not
|
|
62
|
+
* authenticate — a payload in another format, or one written under a secret that has since been
|
|
63
|
+
* rotated — so a stale row reads as "no key saved" instead of failing the request.
|
|
64
|
+
*/
|
|
65
|
+
export const decryptSecret = (payload: string): string | null => {
|
|
66
|
+
const [version, salt, iv, tag, encrypted] = payload.split('.');
|
|
67
|
+
if (version !== ENCRYPTED_SECRET_VERSION || !salt || !iv || !tag || !encrypted) return null;
|
|
68
|
+
try {
|
|
69
|
+
const decipher = createDecipheriv('aes-256-gcm', deriveKey(Buffer.from(salt, 'base64url')), Buffer.from(iv, 'base64url'));
|
|
70
|
+
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
|
|
71
|
+
return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8');
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
@@ -25,7 +25,17 @@ export {
|
|
|
25
25
|
type ResendUserInvitationResult,
|
|
26
26
|
type ConsumeAuthActionResult,
|
|
27
27
|
} from './action-tokens';
|
|
28
|
-
export {
|
|
28
|
+
export {
|
|
29
|
+
AI_KEY_SECRET_ENV,
|
|
30
|
+
MissingEncryptionSecretError,
|
|
31
|
+
createOpaqueSecret,
|
|
32
|
+
decryptSecret,
|
|
33
|
+
encryptSecret,
|
|
34
|
+
hashSecret,
|
|
35
|
+
normalizeEmail,
|
|
36
|
+
secretHashMatches,
|
|
37
|
+
} from './crypto';
|
|
38
|
+
export { deleteRegistryAiKey, listRegistryAiKeyConnections, readRegistryAiKey, saveRegistryAiKey } from './ai-keys';
|
|
29
39
|
export { buildGravatarUrl } from './gravatar';
|
|
30
40
|
export { authenticateRegistrySyncSecret, resolveRegistrySyncSecret, SYNC_SECRET_ENV } from './sync-secret';
|
|
31
41
|
export {
|
|
@@ -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> {
|