handoff-app 2.0.0-7 → 2.0.0-9
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/README.md +130 -14
- package/UPGRADE.md +20 -0
- package/dist/app-builder/build.js +10 -2
- package/dist/app-builder/client-config.js +3 -1
- package/dist/app-builder/watchers.js +17 -7
- package/dist/cache/build-cache.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/auth/device.d.ts +6 -1
- package/dist/cli/auth/device.js +5 -2
- package/dist/cli/auth/store.d.ts +15 -6
- package/dist/cli/auth/store.js +72 -19
- package/dist/commands/login/index.d.ts +5 -0
- package/dist/commands/login/index.js +18 -6
- package/dist/commands/logout/index.d.ts +5 -0
- package/dist/commands/logout/index.js +54 -15
- package/dist/commands/types.d.ts +2 -0
- package/dist/commands/utils.d.ts +21 -7
- package/dist/commands/utils.js +11 -10
- package/dist/config/catalog-include.d.ts +2 -2
- package/dist/config/defaults.d.ts +3 -3
- package/dist/config/defaults.js +54 -48
- package/dist/config/env.d.ts +9 -0
- package/dist/config/env.js +43 -0
- package/dist/config/errors.d.ts +4 -0
- package/dist/config/errors.js +11 -0
- package/dist/config/from-env.d.ts +24 -0
- package/dist/config/from-env.js +7 -0
- package/dist/config/helpers.d.ts +2 -0
- package/dist/config/helpers.js +3 -1
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.js +6 -1
- package/dist/config/loader.d.ts +29 -11
- package/dist/config/loader.js +121 -46
- package/dist/config/resolve-env.d.ts +5 -0
- package/dist/config/resolve-env.js +102 -0
- package/dist/config/runtime.d.ts +3 -3
- package/dist/config/runtime.js +8 -10
- package/dist/config/validator.d.ts +2 -2
- package/dist/config/validator.js +2 -4
- package/dist/handlebars.d.ts +10 -2
- package/dist/index.d.ts +33 -4
- package/dist/index.js +60 -16
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/registry/asset-storage/adapters/vercel-blob.js +1 -1
- package/dist/registry/asset-storage/resolve.d.ts +2 -2
- package/dist/registry/asset-storage/resolve.js +4 -1
- package/dist/registry/checkout/index.js +4 -4
- package/dist/registry/connection.d.ts +28 -24
- package/dist/registry/connection.js +53 -67
- package/dist/registry/db/driver.d.ts +4 -11
- package/dist/registry/db/driver.js +4 -16
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +1 -2
- package/dist/registry/publish/index.js +4 -4
- package/dist/store/filesystem.d.ts +2 -2
- package/dist/transformers/plugins/ssr-render.js +40 -15
- package/dist/transformers/preview/component/css.d.ts +1 -0
- package/dist/transformers/preview/component/css.js +85 -47
- 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 +19 -0
- package/dist/transformers/utils/build.js +83 -1
- package/dist/transformers/utils/module.js +2 -1
- package/dist/types/config.d.ts +54 -29
- package/docs/api.md +31 -0
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/src/app/components/Component/PageSliceResolver.tsx +1 -0
- package/src/app/components/Component/Preview.tsx +380 -189
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
- package/src/app/pages/account/tokens.tsx +3 -1
- package/src/app-builder/build.ts +10 -2
- package/src/app-builder/client-config.ts +5 -3
- package/src/app-builder/watchers.ts +18 -8
- package/src/cache/build-cache.ts +5 -1
- 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/auth/device.ts +8 -3
- package/src/cli/auth/store.ts +75 -18
- package/src/commands/login/index.ts +20 -6
- package/src/commands/logout/index.ts +74 -20
- package/src/commands/types.ts +2 -0
- package/src/commands/utils.ts +14 -5
- package/src/config/README.md +61 -1
- package/src/config/catalog-include.ts +2 -2
- package/src/config/defaults.ts +24 -14
- package/src/config/env.ts +41 -0
- package/src/config/errors.ts +7 -0
- package/src/config/from-env.ts +26 -0
- package/src/config/helpers.ts +4 -2
- package/src/config/index.ts +4 -1
- package/src/config/loader.ts +158 -50
- package/src/config/resolve-env.ts +94 -0
- package/src/config/runtime.ts +12 -7
- package/src/config/validator.ts +4 -6
- package/src/handlebars.ts +8 -2
- package/src/index.d.ts +2 -2
- package/src/index.ts +85 -8
- package/src/pipeline/index.ts +1 -1
- package/src/registry/asset-storage/adapters/vercel-blob.ts +1 -1
- package/src/registry/asset-storage/resolve.ts +6 -3
- package/src/registry/checkout/index.ts +5 -5
- package/src/registry/connection.ts +72 -71
- package/src/registry/db/driver.ts +6 -17
- package/src/registry/db/index.ts +0 -1
- package/src/registry/publish/index.ts +5 -5
- package/src/store/filesystem.ts +2 -2
- package/src/transformers/plugins/ssr-render.ts +53 -16
- package/src/transformers/preview/component/css.ts +93 -50
- package/src/transformers/preview/pattern/html.ts +3 -1
- package/src/transformers/preview/types.ts +14 -0
- package/src/transformers/utils/build.ts +80 -0
- package/src/transformers/utils/module.ts +4 -2
- package/src/types/config.ts +51 -29
- package/templates/default/env.tpl +10 -0
- package/templates/default/gitignore.tpl +7 -0
- package/templates/default/handoff.config.js.tpl +21 -172
- package/templates/default/handoff.config.ts.tpl +21 -156
- package/templates/default/handoff.config.with-examples.js.tpl +20 -171
- package/templates/default/handoff.config.with-examples.ts.tpl +20 -155
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import isPlainObject from 'lodash/isPlainObject';
|
|
2
|
+
import type { Config, ResolvedConfig } from '../types/config';
|
|
3
|
+
import { Logger } from '../utils/logger';
|
|
4
|
+
import { isEnvReference } from './from-env';
|
|
5
|
+
import { HandoffConfigError } from './errors';
|
|
6
|
+
|
|
7
|
+
export const DEFERRED_PATHS = new Set(['runtime.registry.databaseUrl', 'runtime.registry.assetStorage.token']);
|
|
8
|
+
const SECRET_PATHS = new Set(['dev_access_token', 'devAccessToken', 'runtime.registryConnection.accessToken']);
|
|
9
|
+
/** Path prefixes whose resolved contents are JSON-baked into the bundle, so a reference would bake its value. */
|
|
10
|
+
const BAKED_PATHS = ['runtime.registry.assetStorage.options'];
|
|
11
|
+
const isBaked = (path: string): boolean => BAKED_PATHS.some((baked) => path === baked || path.startsWith(`${baked}.`));
|
|
12
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
|
+
const ALIASES: Record<string, string> = {
|
|
14
|
+
HANDOFF_REGISTRY_URL: 'HANDOFF_CLOUD_URL',
|
|
15
|
+
HANDOFF_REGISTRY_ACCESS_TOKEN: 'HANDOFF_CLOUD_TOKEN',
|
|
16
|
+
};
|
|
17
|
+
const warnedAliases = new Set<string>();
|
|
18
|
+
const referenceNames = new WeakMap<ResolvedConfig, Map<string, string>>();
|
|
19
|
+
|
|
20
|
+
export const configEnvName = (config: ResolvedConfig | null | undefined, path: string): string | undefined =>
|
|
21
|
+
referenceNames.get(config)?.get(path);
|
|
22
|
+
|
|
23
|
+
const readEnv = (name: string): string | undefined => {
|
|
24
|
+
const value = process.env[name];
|
|
25
|
+
const alias = ALIASES[name];
|
|
26
|
+
if (!alias || value?.trim()) return value;
|
|
27
|
+
const inherited = process.env[alias];
|
|
28
|
+
if (!inherited?.trim()) return value;
|
|
29
|
+
if (!warnedAliases.has(alias)) {
|
|
30
|
+
warnedAliases.add(alias);
|
|
31
|
+
Logger.warn(`${alias} is deprecated and will be removed in a future release; set ${name} instead.`);
|
|
32
|
+
}
|
|
33
|
+
return inherited;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Resolve only the merged winner. Functions and non-plain objects retain their identity. */
|
|
37
|
+
export const resolveConfigEnv = (config: Config, profile?: string, defaults?: Config): ResolvedConfig => {
|
|
38
|
+
const names = new Map<string, string>();
|
|
39
|
+
const fail = (path: string, message: string): never => {
|
|
40
|
+
throw new HandoffConfigError(`Config "${path}" (profile "${profile ?? 'default'}"): ${message}`);
|
|
41
|
+
};
|
|
42
|
+
const walk = (value: unknown, path: string, seed?: unknown): unknown => {
|
|
43
|
+
const deferred = DEFERRED_PATHS.has(path);
|
|
44
|
+
const secret = SECRET_PATHS.has(path);
|
|
45
|
+
if ((secret || deferred) && value !== undefined && !isEnvReference(value)) {
|
|
46
|
+
fail(path, 'Expected an environment reference, such as { $env: "VARIABLE_NAME" }. Literal values are not allowed.');
|
|
47
|
+
}
|
|
48
|
+
if (isEnvReference(value)) {
|
|
49
|
+
// Checked before the name, so the message names the supported pattern instead of the reference shape.
|
|
50
|
+
if (isBaked(path)) {
|
|
51
|
+
fail(
|
|
52
|
+
path,
|
|
53
|
+
'Environment references are not allowed here because the resolved value is baked into the build. ' +
|
|
54
|
+
"Put the variable name in options and read it through the adapter factory's `env` argument."
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (typeof value.$env !== 'string' || !ENV_NAME.test(value.$env)) {
|
|
58
|
+
fail(path, 'Invalid environment variable name. Use letters, digits, and underscores, starting with a letter or underscore.');
|
|
59
|
+
}
|
|
60
|
+
const name = value.$env;
|
|
61
|
+
names.set(path, name);
|
|
62
|
+
if (Object.keys(value).some((key) => key !== '$env' && key !== 'default')) {
|
|
63
|
+
fail(path, `Environment variable "${name}" has an invalid reference shape.`);
|
|
64
|
+
}
|
|
65
|
+
if (deferred) {
|
|
66
|
+
if ('default' in value) fail(path, `Environment variable "${name}" cannot have a default because it is read by the deployed app.`);
|
|
67
|
+
return { $env: name };
|
|
68
|
+
}
|
|
69
|
+
// Secret defaults can mark an optional credential, but cannot embed a credential value.
|
|
70
|
+
if (secret && value.default !== undefined && value.default !== null && value.default !== '') {
|
|
71
|
+
fail(path, `Environment variable "${name}" cannot have a literal secret default.`);
|
|
72
|
+
}
|
|
73
|
+
const raw = readEnv(name);
|
|
74
|
+
if (raw === undefined) {
|
|
75
|
+
if (Object.prototype.hasOwnProperty.call(value, 'default')) return secret ? value.default : walk(value.default, path, seed);
|
|
76
|
+
fail(path, `Environment variable "${name}" is not set and has no default.`);
|
|
77
|
+
}
|
|
78
|
+
const typeHint = value.default ?? (isEnvReference(seed) ? seed.default : seed);
|
|
79
|
+
if (typeof typeHint === 'boolean') return raw === 'true';
|
|
80
|
+
if (typeof typeHint === 'number') {
|
|
81
|
+
const number = Number(raw);
|
|
82
|
+
if (!Number.isFinite(number)) fail(path, `Environment variable "${name}" must contain a finite number.`);
|
|
83
|
+
return number;
|
|
84
|
+
}
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(value)) return value.map((entry, index) => walk(entry, `${path}.${index}`, seed?.[index]));
|
|
88
|
+
if (!isPlainObject(value)) return value;
|
|
89
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, walk(entry, path ? `${path}.${key}` : key, seed?.[key])]));
|
|
90
|
+
};
|
|
91
|
+
const resolved = walk(config, '', defaults) as ResolvedConfig;
|
|
92
|
+
referenceNames.set(resolved, names);
|
|
93
|
+
return resolved;
|
|
94
|
+
};
|
package/src/config/runtime.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import esbuild from 'esbuild';
|
|
2
|
+
import { STYLE_IMPORT_LOADERS } from '../transformers/utils/build';
|
|
2
3
|
import fs from 'fs-extra';
|
|
3
4
|
import { createRequire } from 'module';
|
|
4
5
|
import path from 'path';
|
|
@@ -14,7 +15,7 @@ import { HOME_PAGE_ID, HOME_PAGE_PATH } from '../registry/content-kinds';
|
|
|
14
15
|
import { ComponentListObject } from '../transformers/preview/types';
|
|
15
16
|
import { createCsfStoryPreviews } from '../transformers/utils/csf';
|
|
16
17
|
import { buildAndEvaluateModuleSync } from '../transformers/utils/module';
|
|
17
|
-
import {
|
|
18
|
+
import { ResolvedConfig, ConfigFileEntry, RuntimeConfig } from '../types/config';
|
|
18
19
|
import { Logger } from '../utils/logger';
|
|
19
20
|
import { parseMarkdown } from '../utils/markdown';
|
|
20
21
|
import { collectPageSlugSegments } from '../utils/pages';
|
|
@@ -26,7 +27,7 @@ import { normalizePageDeclaration } from './normalizers/page';
|
|
|
26
27
|
* Avoids importing the full Handoff class to prevent circular deps.
|
|
27
28
|
*/
|
|
28
29
|
interface HandoffContext {
|
|
29
|
-
config:
|
|
30
|
+
config: ResolvedConfig;
|
|
30
31
|
workingPath: string;
|
|
31
32
|
modulePath: string;
|
|
32
33
|
}
|
|
@@ -35,7 +36,8 @@ type DeclarationResolution = {
|
|
|
35
36
|
fileName: string;
|
|
36
37
|
};
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
/** `.tsx`/`.jsx` are accepted so a preview can write its `render` as JSX. */
|
|
40
|
+
const DECLARATION_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js', 'cjs'] as const;
|
|
39
41
|
|
|
40
42
|
const getDeclarationFiles = (componentBaseName: string): string[] =>
|
|
41
43
|
DECLARATION_EXTENSIONS.map((ext) => `${componentBaseName}.handoff.${ext}`);
|
|
@@ -46,7 +48,7 @@ const findPreferredDeclaration = (componentDir: string, componentBaseName: strin
|
|
|
46
48
|
if (exactMatch) return exactMatch;
|
|
47
49
|
|
|
48
50
|
const allFiles = fs.existsSync(componentDir) ? fs.readdirSync(componentDir) : [];
|
|
49
|
-
const declarationFiles = allFiles.filter((file) => /\.handoff\.(ts|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
|
|
51
|
+
const declarationFiles = allFiles.filter((file) => /\.handoff\.(tsx|ts|jsx|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
|
|
50
52
|
|
|
51
53
|
if (!declarationFiles.length) return undefined;
|
|
52
54
|
|
|
@@ -74,6 +76,7 @@ const evaluateDeclaration = (filePath: string, handoffModulePath: string): any =
|
|
|
74
76
|
logLevel: 'silent',
|
|
75
77
|
jsx: 'automatic',
|
|
76
78
|
external: ['react', 'react-dom', 'handoff-app'],
|
|
79
|
+
loader: STYLE_IMPORT_LOADERS,
|
|
77
80
|
});
|
|
78
81
|
|
|
79
82
|
const code = buildResult.outputFiles?.[0]?.text;
|
|
@@ -129,7 +132,7 @@ const discoverCsfPreviews = (component: ComponentListObject, handoff: HandoffCon
|
|
|
129
132
|
/**
|
|
130
133
|
* Expands catalog.include into item directories and removes duplicate paths.
|
|
131
134
|
*/
|
|
132
|
-
export const getCatalogItemDirectories = (config:
|
|
135
|
+
export const getCatalogItemDirectories = (config: ResolvedConfig, workingPath: string): string[] => {
|
|
133
136
|
const registered = [...(config.catalog?.include ?? [])];
|
|
134
137
|
|
|
135
138
|
const seen = new Set<string>();
|
|
@@ -219,7 +222,7 @@ export const initRuntimeConfig = (
|
|
|
219
222
|
if (!declaration) {
|
|
220
223
|
const declarationFiles = getDeclarationFiles(itemBaseName);
|
|
221
224
|
Logger.warn(
|
|
222
|
-
`Missing catalog declaration (use .handoff.ts, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path.resolve(itemPath, declarationFiles.join(' or '))}`
|
|
225
|
+
`Missing catalog declaration (use .handoff.tsx, .handoff.ts, .handoff.jsx, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path.resolve(itemPath, declarationFiles.join(' or '))}`
|
|
223
226
|
);
|
|
224
227
|
continue;
|
|
225
228
|
}
|
|
@@ -433,9 +436,9 @@ const injectPatternPreviews = (result: RuntimeConfig): void => {
|
|
|
433
436
|
|
|
434
437
|
// Case 3: args present (with or without preview base) -> create synthetic preview
|
|
435
438
|
let resolvedValues: Record<string, any> = {};
|
|
439
|
+
const basePreview = ref.preview ? component.previews?.[ref.preview] : undefined;
|
|
436
440
|
|
|
437
441
|
if (ref.preview) {
|
|
438
|
-
const basePreview = component.previews?.[ref.preview];
|
|
439
442
|
if (basePreview) {
|
|
440
443
|
resolvedValues = { ...basePreview.values };
|
|
441
444
|
} else {
|
|
@@ -454,6 +457,8 @@ const injectPatternPreviews = (result: RuntimeConfig): void => {
|
|
|
454
457
|
values: resolvedValues,
|
|
455
458
|
url: '',
|
|
456
459
|
sourcePreview: ref.preview,
|
|
460
|
+
// The ref overrides arguments, not how the preview renders, so a base `render` still applies.
|
|
461
|
+
...(basePreview?.render ? { render: basePreview.render } : {}),
|
|
457
462
|
};
|
|
458
463
|
|
|
459
464
|
ref.resolvedPreview = syntheticKey;
|
package/src/config/validator.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ResolvedConfig } from '../types/config';
|
|
2
2
|
import { Logger } from '../utils/logger';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -8,15 +8,13 @@ import { Logger } from '../utils/logger';
|
|
|
8
8
|
* @returns The validated config (same reference).
|
|
9
9
|
* @throws {Error} If required Figma credentials are missing.
|
|
10
10
|
*/
|
|
11
|
-
export const validateConfig = (config:
|
|
11
|
+
export const validateConfig = (config: ResolvedConfig): ResolvedConfig => {
|
|
12
12
|
// TODO: Check to see if the exported folder exists before we run start
|
|
13
|
-
if (!config.figma_project_id
|
|
14
|
-
// check to see if we can get this from the env
|
|
13
|
+
if (!config.figma_project_id) {
|
|
15
14
|
Logger.error('Figma Project ID missing. Please set HANDOFF_FIGMA_PROJECT_ID or run "handoff-app fetch".');
|
|
16
15
|
throw new Error('Cannot initialize configuration');
|
|
17
16
|
}
|
|
18
|
-
if (!config.dev_access_token
|
|
19
|
-
// check to see if we can get this from the env
|
|
17
|
+
if (!config.dev_access_token) {
|
|
20
18
|
Logger.error('Figma Access Token missing. Please set HANDOFF_DEV_ACCESS_TOKEN or run "handoff-app fetch".');
|
|
21
19
|
throw new Error('Cannot initialize configuration');
|
|
22
20
|
}
|
package/src/handlebars.ts
CHANGED
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
* an argument type to check previews: `defineCatalogItem<BadgeArgs>`.
|
|
6
6
|
*/
|
|
7
7
|
import { createCatalogItem } from './catalog/define';
|
|
8
|
-
import type { CatalogItem, CatalogItemMeta } from './catalog/types';
|
|
8
|
+
import type { CatalogItem, CatalogItemMeta, CatalogPreview as RendererPreview } from './catalog/types';
|
|
9
9
|
|
|
10
|
-
export type { CatalogItem, CatalogItemMeta,
|
|
10
|
+
export type { CatalogItem, CatalogItemMeta, CompositionRef, SourceDescriptor } from './catalog/types';
|
|
11
|
+
|
|
12
|
+
/** A template renderer never calls a preview `render`, so declaring one is an error, not a no-op. */
|
|
13
|
+
export type CatalogPreview<TArgs = Record<string, unknown>> = Omit<RendererPreview<TArgs>, 'render'> & { render?: never };
|
|
14
|
+
|
|
15
|
+
/** Preview type for a Handlebars catalog item, with its argument type applied. */
|
|
16
|
+
export type Preview<TItem> = TItem extends { readonly __args?: infer TArgs } ? CatalogPreview<Partial<NonNullable<TArgs>>> : never;
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Declares a Handlebars catalog item. `implementation` is the path to its `.hbs` template. The
|
package/src/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
|
|
2
2
|
import 'dotenv/config';
|
|
3
|
-
import {
|
|
3
|
+
import { ResolvedConfig } from './types/config';
|
|
4
4
|
|
|
5
5
|
declare module 'handoff-app';
|
|
6
6
|
|
|
7
7
|
declare class Handoff {
|
|
8
|
-
config:
|
|
8
|
+
config: ResolvedConfig;
|
|
9
9
|
modulePath: string;
|
|
10
10
|
workingPath: string;
|
|
11
11
|
force: boolean;
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
|
-
import '
|
|
1
|
+
import './config/env';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { Types as CoreTypes, Handoff as HandoffRunner, Providers } from 'handoff-core';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import buildApp, { devApp, watchApp, type BuildPackage, type BuildTarget } from './app-builder';
|
|
6
6
|
import { ejectConfig, ejectPages, ejectTheme } from './cli/eject';
|
|
7
7
|
import { makeComponent, makePage } from './cli/make';
|
|
8
|
-
import {
|
|
8
|
+
import { loginProfileNames } from './cli/auth/store';
|
|
9
|
+
import {
|
|
10
|
+
type ConfigLoadContext,
|
|
11
|
+
initConfigWithMetadata,
|
|
12
|
+
initRuntimeConfig,
|
|
13
|
+
loadProfileEnv,
|
|
14
|
+
resolveProfileSelection,
|
|
15
|
+
validateConfig,
|
|
16
|
+
} from './config';
|
|
9
17
|
import pipeline, { buildComponents, buildPatterns } from './pipeline';
|
|
10
18
|
import { ALL_KIND_ORDER, ENTITY_WIRE_KIND, isRegistryEntityKind, REGISTRY_ENTITY_KINDS, type RegistryEntityKind } from './registry/content-kinds';
|
|
11
19
|
import type { TransferEntityKind } from './registry/transfer';
|
|
12
20
|
import { createFilesystemStore, type HandoffStore } from './store';
|
|
13
21
|
import processComponents, { ComponentSegment } from './transformers/preview/component/builder';
|
|
14
|
-
import { Config, ConfigFileEntry, RuntimeConfig } from './types/config';
|
|
22
|
+
import { Config, ResolvedConfig, ConfigFileEntry, RuntimeConfig } from './types/config';
|
|
15
23
|
import { Logger } from './utils/logger';
|
|
16
24
|
import { normalizePathForCompare, resolveWorkingPath } from './utils/path';
|
|
17
25
|
import { generateFilesystemSafeId } from './utils/path';
|
|
@@ -32,6 +40,19 @@ export interface HandoffOptions {
|
|
|
32
40
|
config?: Partial<Config>;
|
|
33
41
|
/** Explicit config file (the CLI's `-c, --config`), resolved from the working path. */
|
|
34
42
|
configPath?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Config profile to merge onto the base config (the CLI's `--profile`). It overrides
|
|
45
|
+
* `HANDOFF_PROFILE`, and a selected profile has to resolve to a `handoff.config.<profile>.*` file
|
|
46
|
+
* unless {@link profileWithoutConfig} widens that.
|
|
47
|
+
*/
|
|
48
|
+
profile?: string;
|
|
49
|
+
/**
|
|
50
|
+
* What a selected profile may resolve to when it has no config file. `'saved-login'` accepts a name
|
|
51
|
+
* a saved registry login uses, so publish and checkout can address a registry that no config file
|
|
52
|
+
* describes. `'any'` accepts any valid name, for the `login` command that creates the login. Unset,
|
|
53
|
+
* a profile must name a config file, so a typo cannot silently run with the base config.
|
|
54
|
+
*/
|
|
55
|
+
profileWithoutConfig?: 'saved-login' | 'any';
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
/**
|
|
@@ -109,7 +130,7 @@ const runKinds = async (
|
|
|
109
130
|
};
|
|
110
131
|
|
|
111
132
|
class Handoff {
|
|
112
|
-
config:
|
|
133
|
+
config: ResolvedConfig | null;
|
|
113
134
|
debug: boolean = false;
|
|
114
135
|
force: boolean = false;
|
|
115
136
|
dryRun: boolean = false;
|
|
@@ -142,6 +163,8 @@ class Handoff {
|
|
|
142
163
|
private _configFilePaths: string[] = [];
|
|
143
164
|
private _configFileIndex: Map<string, ConfigFileEntry> = new Map();
|
|
144
165
|
private _mainConfigFilePath?: string;
|
|
166
|
+
private _profileConfigFilePath?: string;
|
|
167
|
+
private _profile?: string;
|
|
145
168
|
private _documentationObjectCache?: CoreTypes.IDocumentationObject;
|
|
146
169
|
private _handoffRunner?: ReturnType<typeof HandoffRunner> | null;
|
|
147
170
|
|
|
@@ -151,7 +174,8 @@ class Handoff {
|
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
private construct(options: HandoffOptions) {
|
|
154
|
-
|
|
177
|
+
// Ahead of `Logger.init` so a profile env file can set `HANDOFF_LOG_LEVEL` and `HANDOFF_LOG_SCOPES`.
|
|
178
|
+
loadProfileEnv(resolveProfileSelection(options.profile)?.name);
|
|
155
179
|
this.debug = options.debug ?? false;
|
|
156
180
|
this.force = options.force ?? false;
|
|
157
181
|
this.dryRun = options.dryRun ?? false;
|
|
@@ -160,14 +184,50 @@ class Handoff {
|
|
|
160
184
|
this.init(options.config);
|
|
161
185
|
}
|
|
162
186
|
|
|
187
|
+
/**
|
|
188
|
+
* What a selected profile may resolve to besides a config file, and how to explain a name that
|
|
189
|
+
* resolves to nothing. A command that does not accept a login still names one it found, so the
|
|
190
|
+
* difference between the two kinds of profile is visible rather than puzzling.
|
|
191
|
+
*/
|
|
192
|
+
private profilesWithoutConfig(): Pick<ConfigLoadContext, 'knownProfiles' | 'profileNotFoundHint'> {
|
|
193
|
+
const selected = resolveProfileSelection(this._options.profile)?.name;
|
|
194
|
+
if (!selected) {
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const accepts = this._options.profileWithoutConfig;
|
|
199
|
+
const names = loginProfileNames(this.workingPath);
|
|
200
|
+
if (!accepts) {
|
|
201
|
+
return names.includes(selected)
|
|
202
|
+
? {
|
|
203
|
+
profileNotFoundHint:
|
|
204
|
+
'A saved registry login uses that name, but only login, logout, publish, and checkout accept a profile without a config file.',
|
|
205
|
+
}
|
|
206
|
+
: {};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
knownProfiles: accepts === 'any' ? [...names, selected] : names,
|
|
211
|
+
profileNotFoundHint:
|
|
212
|
+
'No saved registry login uses that name either. Run `handoff-app login --profile <name> --url <registry-url>` to create one.',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
163
216
|
init(configOverride?: Partial<Config>): Handoff {
|
|
217
|
+
// Resolved before anything is assigned: a reload that throws - a deleted profile file, a
|
|
218
|
+
// broken config - then leaves the last good state in place for the watchers holding this
|
|
219
|
+
// instance.
|
|
164
220
|
const configResult = initConfigWithMetadata(configOverride ?? {}, {
|
|
165
221
|
workingPath: this.workingPath,
|
|
166
222
|
configPath: this._options.configPath,
|
|
223
|
+
profile: this._options.profile,
|
|
224
|
+
...this.profilesWithoutConfig(),
|
|
167
225
|
});
|
|
168
226
|
const config = configResult.config;
|
|
169
227
|
this.config = config;
|
|
170
228
|
this._mainConfigFilePath = configResult.configPath;
|
|
229
|
+
this._profileConfigFilePath = configResult.profileConfigPath;
|
|
230
|
+
this._profile = configResult.profile;
|
|
171
231
|
this.exportsDirectory = config.exportsOutputDirectory ?? this.exportsDirectory;
|
|
172
232
|
this.sitesDirectory = config.sitesOutputDirectory ?? this.exportsDirectory;
|
|
173
233
|
[this.runtimeConfig, this._configFilePaths, this._configFileIndex] = initRuntimeConfig(this);
|
|
@@ -571,8 +631,8 @@ class Handoff {
|
|
|
571
631
|
* @returns {string[]} Array of absolute paths to config files
|
|
572
632
|
*/
|
|
573
633
|
getConfigFilePaths(): string[] {
|
|
574
|
-
const
|
|
575
|
-
return Array.from(new Set(
|
|
634
|
+
const mainPaths = [this._mainConfigFilePath, this._profileConfigFilePath].filter(Boolean) as string[];
|
|
635
|
+
return Array.from(new Set([...mainPaths, ...this._configFilePaths]));
|
|
576
636
|
}
|
|
577
637
|
|
|
578
638
|
/**
|
|
@@ -582,6 +642,21 @@ class Handoff {
|
|
|
582
642
|
return this._mainConfigFilePath;
|
|
583
643
|
}
|
|
584
644
|
|
|
645
|
+
getProfile(): string | undefined {
|
|
646
|
+
return this._profile;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* True for the base config file and for the profile file merged onto it. Both carry project-wide
|
|
651
|
+
* settings, so a change to either one invalidates every entity rather than a single declaration.
|
|
652
|
+
*/
|
|
653
|
+
isMainConfigFile(filePath: string): boolean {
|
|
654
|
+
const normalized = normalizePathForCompare(filePath);
|
|
655
|
+
return [this._mainConfigFilePath, this._profileConfigFilePath]
|
|
656
|
+
.filter(Boolean)
|
|
657
|
+
.some((mainPath) => normalizePathForCompare(mainPath as string) === normalized);
|
|
658
|
+
}
|
|
659
|
+
|
|
585
660
|
getConfigFileEntry(configPath: string): ConfigFileEntry | undefined {
|
|
586
661
|
return this._configFileIndex.get(normalizePathForCompare(configPath));
|
|
587
662
|
}
|
|
@@ -608,8 +683,10 @@ class Handoff {
|
|
|
608
683
|
}
|
|
609
684
|
}
|
|
610
685
|
|
|
611
|
-
export type { Config, RegisterHandlebarsHelpersContext } from './types/config';
|
|
686
|
+
export type { Config, ResolvedConfig, RegisterHandlebarsHelpersContext } from './types/config';
|
|
612
687
|
export { defineConfig } from './config';
|
|
688
|
+
export { fromEnv } from './config/from-env';
|
|
689
|
+
export type { EnvValue, EnvSecret, RuntimeEnvReference } from './config/from-env';
|
|
613
690
|
export type {
|
|
614
691
|
CatalogItem,
|
|
615
692
|
CatalogItemEntries,
|
package/src/pipeline/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const createVercelBlobStorage = (params: { tokenEnv: string; options: Rec
|
|
|
21
21
|
if (!token) {
|
|
22
22
|
throw new Error(
|
|
23
23
|
`Vercel Blob storage is configured but the token env var "${params.tokenEnv}" is not set. ` +
|
|
24
|
-
'Set it to a Blob read/write token, or change runtime.registry.assetStorage.
|
|
24
|
+
'Set it to a Blob read/write token, or change runtime.registry.assetStorage.token.'
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
return token;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* env-var *names* are ever persisted; secret *values* are read from `process.env` at request time.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type { ResolvedConfig } from '../../types/config';
|
|
11
11
|
import type { AssetStorageProvider } from '../db/schema';
|
|
12
12
|
|
|
13
13
|
/** Which adapter implementation is active. */
|
|
@@ -68,5 +68,8 @@ export const resolveAssetStorageSettings = (settings: AssetStorageSettings | nul
|
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
/** Resolve the active asset storage settings from a loaded config (CLI/build side). */
|
|
71
|
-
export const resolveAssetStorageFromConfig = (config:
|
|
72
|
-
resolveAssetStorageSettings(
|
|
71
|
+
export const resolveAssetStorageFromConfig = (config: ResolvedConfig | null | undefined): ResolvedAssetStorage =>
|
|
72
|
+
resolveAssetStorageSettings({
|
|
73
|
+
...config?.runtime?.registry?.assetStorage,
|
|
74
|
+
tokenEnv: config?.runtime?.registry?.assetStorage?.token?.$env,
|
|
75
|
+
});
|
|
@@ -36,7 +36,7 @@ import Handoff from '../../index';
|
|
|
36
36
|
import type { DeclarationFormat } from '../../types/config';
|
|
37
37
|
import { Logger } from '../../utils/logger';
|
|
38
38
|
import { createRegistryClient, type RegistryClient, RegistryClientError } from '../client';
|
|
39
|
-
import { resolveAuthenticatedRegistryConnection } from '../connection';
|
|
39
|
+
import { loginCommandHint, resolveAuthenticatedRegistryConnection, unusedLoginNote } from '../connection';
|
|
40
40
|
import { isSafePathSegment, isSafeRelativePath, resolvePathWithin } from '../path';
|
|
41
41
|
import { quoteIds, selectIds, splitCatalogIds } from '../selection';
|
|
42
42
|
import type { CheckoutPayload, TransferEntityKind, TransferFile } from '../transfer';
|
|
@@ -76,17 +76,17 @@ export const resolveConnectionOrThrow = async (handoff: Handoff) => {
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
|
|
79
|
+
const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath, handoff.getProfile());
|
|
80
80
|
if (!connection.url) {
|
|
81
81
|
throw new CheckoutError(
|
|
82
|
-
`No registry is configured. Run
|
|
82
|
+
`No registry is configured. Run \`${loginCommandHint(connection)}\`, set runtime.registryConnection.url, ` +
|
|
83
83
|
`or set the "${connection.urlEnv}" environment variable to the base URL of the registry to checkout from.`
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
86
|
if (!connection.accessToken) {
|
|
87
87
|
throw new CheckoutError(
|
|
88
|
-
`No registry access token is configured. Run
|
|
89
|
-
`"${connection.accessTokenEnv}" environment variable to a user-issued token for CI
|
|
88
|
+
`No registry access token is configured. Run \`${loginCommandHint(connection)}\`, or set the ` +
|
|
89
|
+
`"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.${unusedLoginNote(connection)}`
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
return connection;
|