@swell/cli 2.9.26 → 2.9.27

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.
@@ -39,5 +39,6 @@ export default class AppDev extends PushAppCommand {
39
39
  private logFunction;
40
40
  private onChangeFunctionWatcher;
41
41
  private startAppFunctionServer;
42
+ private logComponentModules;
42
43
  private startFunctionServers;
43
44
  }
@@ -7,6 +7,7 @@ import * as http from 'node:http';
7
7
  import * as os from 'node:os';
8
8
  import * as path from 'node:path';
9
9
  import ora from 'ora';
10
+ import { COMPONENT_DEV_PATH, listRootComponentFiles, serveComponentModule, } from '../../lib/apps/components.js';
10
11
  import { ConfigType, allConfigFilesInDir, appConfigFromFile, } from '../../lib/apps/index.js';
11
12
  import { bundleFunction } from '../../lib/bundle.js';
12
13
  import { FUNCTION_COMPATIBILITY_DATE, getLocalFrontendHost, } from '../../lib/constants.js';
@@ -194,6 +195,10 @@ export default class AppDev extends PushAppCommand {
194
195
  res.writeHead(status);
195
196
  res.end(message);
196
197
  };
198
+ if (url.pathname.startsWith(COMPONENT_DEV_PATH)) {
199
+ serveComponentModule(this.appPath, url.pathname, res).catch((error) => fail(500, error.message));
200
+ return;
201
+ }
197
202
  if (this.functionPorts.has(functionName)) {
198
203
  const targetPort = this.functionPorts.get(functionName);
199
204
  // Log when response is finished, method, status, and time to execute by proxy
@@ -443,15 +448,26 @@ ENVIRONMENT = "development"
443
448
  async startAppFunctionServer(spinner, serverPort) {
444
449
  // Get all functions in this app
445
450
  const functions = await this.getAppFunctions();
451
+ const components = await listRootComponentFiles(this.appPath);
446
452
  const projectType = await this.getFrontendProjectType(false);
447
453
  const shouldLogPreviewUrl = this.app.type === 'storefront' && !projectType;
448
454
  if (functions.length === 0) {
449
- if (!projectType && this.app.type !== 'storefront') {
455
+ if (!projectType &&
456
+ this.app.type !== 'storefront' &&
457
+ components.length === 0) {
450
458
  spinner.stop();
451
- this.log(`No functions or frontend detected. Exiting.\n`);
459
+ this.log(`No functions, components or frontend detected. Exiting.\n`);
452
460
  await this.cleanupOnExit();
453
461
  return;
454
462
  }
463
+ // Components are pushed again when they change; the tmp directory lets
464
+ // a function added while dev runs start like it does in the full mode
465
+ if (components.length > 0) {
466
+ await this.createTmpDirectory();
467
+ this.watchForChanges({
468
+ onChange: this.onChangeFunctionWatcher.bind(this),
469
+ });
470
+ }
455
471
  // Create routing server to proxy requests to frontend even without functions
456
472
  await this.createFunctionRouter(serverPort);
457
473
  if (shouldLogPreviewUrl) {
@@ -461,6 +477,7 @@ ENVIRONMENT = "development"
461
477
  else {
462
478
  spinner.stop();
463
479
  }
480
+ this.logComponentModules(components, serverPort);
464
481
  return;
465
482
  }
466
483
  // Create TMP directory for wrangler configs and bundled functions
@@ -480,6 +497,13 @@ ENVIRONMENT = "development"
480
497
  if (shouldLogPreviewUrl) {
481
498
  this.logLocalPreviewUrl();
482
499
  }
500
+ this.logComponentModules(components, serverPort);
501
+ }
502
+ logComponentModules(components, serverPort) {
503
+ if (components.length === 0) {
504
+ return;
505
+ }
506
+ this.log(`${style.success('→')} Component modules at: http://localhost:${serverPort}${COMPONENT_DEV_PATH}<Name>.js\n`);
483
507
  }
484
508
  async startFunctionServers(functions) {
485
509
  const functionStatus = new Map();
@@ -0,0 +1,19 @@
1
+ import { CreateConfigCommand } from '../../create-config-command.js';
2
+ import { HelpMeta } from '../../help/types.js';
3
+ import { ConfigType } from '../../lib/apps/index.js';
4
+ export default class CreateComponent extends CreateConfigCommand {
5
+ static args: {
6
+ name: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static description: string;
9
+ static examples: string[];
10
+ static helpMeta: HelpMeta;
11
+ static flags: {
12
+ description: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
+ overwrite: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
14
+ yes: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
+ };
16
+ static summary: string;
17
+ createType: ConfigType;
18
+ run(): Promise<void>;
19
+ }
@@ -0,0 +1,84 @@
1
+ import { input } from '@inquirer/prompts';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { CreateConfigCommand } from '../../create-config-command.js';
4
+ import { ConfigType } from '../../lib/apps/index.js';
5
+ import { getComponentTemplate, setupComponentSupport, toComponentName, } from '../../lib/create/component.js';
6
+ export default class CreateComponent extends CreateConfigCommand {
7
+ static args = {
8
+ name: Args.string({
9
+ default: '',
10
+ description: 'Component name (e.g., ColorPicker)',
11
+ }),
12
+ };
13
+ static description = 'Create an app component in the components folder.';
14
+ static examples = [
15
+ '$ swell create component',
16
+ '$ swell create component ColorPicker -d "Brand color picker" -y',
17
+ ];
18
+ static helpMeta = {
19
+ usageDirect: '<name> [-d <description>] -y',
20
+ };
21
+ static flags = {
22
+ description: Flags.string({
23
+ char: 'd',
24
+ default: '',
25
+ description: 'Description',
26
+ }),
27
+ overwrite: Flags.boolean({
28
+ default: false,
29
+ description: 'Overwrite existing file',
30
+ }),
31
+ yes: Flags.boolean({
32
+ char: 'y',
33
+ description: 'Skip prompts, require all arguments',
34
+ }),
35
+ };
36
+ static summary = 'Create an app component in the components folder.';
37
+ createType = ConfigType.COMPONENT;
38
+ async run() {
39
+ const { args, flags } = await this.parse(CreateComponent);
40
+ const { description: descriptionFlag, overwrite, yes } = flags;
41
+ let name = args.name;
42
+ let description = descriptionFlag;
43
+ if (yes) {
44
+ if (!name) {
45
+ this.error('Missing required argument for non-interactive mode: NAME\n\nExample: swell create component ColorPicker -y', { exit: 1 });
46
+ }
47
+ }
48
+ else {
49
+ if (!name) {
50
+ name = await input({
51
+ default: 'MyComponent',
52
+ message: 'Component name',
53
+ });
54
+ }
55
+ if (!description) {
56
+ description = await input({
57
+ message: 'Describe what the component does',
58
+ });
59
+ }
60
+ }
61
+ const componentName = toComponentName(name);
62
+ if (!componentName) {
63
+ this.error(`Invalid component name: ${name}`, { exit: 1 });
64
+ }
65
+ const created = await this.createFile({
66
+ extension: 'tsx',
67
+ fileBody: getComponentTemplate(componentName, description),
68
+ fileName: componentName,
69
+ }, overwrite, !yes);
70
+ if (!created) {
71
+ return;
72
+ }
73
+ const { changed, dependenciesAdded, warnings } = await setupComponentSupport(this.appPath);
74
+ if (changed.length > 0) {
75
+ const install = dependenciesAdded
76
+ ? ' Run `npm install` to install preact and @swell/apps-sdk.'
77
+ : '';
78
+ this.log(`Updated ${changed.join(', ')}.${install}`);
79
+ }
80
+ for (const warning of warnings) {
81
+ this.warn(warning);
82
+ }
83
+ }
84
+ }
@@ -1,7 +1,7 @@
1
1
  import { select } from '@inquirer/prompts';
2
2
  import { Command } from '@oclif/core';
3
3
  export default class Create extends Command {
4
- static description = 'Scaffold apps, models, functions, and notifications.';
4
+ static description = 'Scaffold apps, models, functions, components, and notifications.';
5
5
  static examples = ['<%= config.bin %> <%= command.id %>'];
6
6
  async run() {
7
7
  const createTarget = await select({
@@ -18,6 +18,10 @@ export default class Create extends Command {
18
18
  name: 'Content',
19
19
  value: 'content',
20
20
  },
21
+ {
22
+ name: 'Component',
23
+ value: 'component',
24
+ },
21
25
  {
22
26
  name: 'Function',
23
27
  value: 'function',
@@ -50,6 +50,12 @@ export declare abstract class AppConfig {
50
50
  * @returns {Promise<string | undefined>} the hash
51
51
  */
52
52
  protected contentHash(): Promise<string | undefined>;
53
+ /**
54
+ * File hash combined with the app files this file imports, for configs that
55
+ * deploy as a bundle: a change in shared code redeploys them
56
+ * @returns {Promise<string | undefined>} the hash, or undefined until the file compiles
57
+ */
58
+ protected bundleHash(): Promise<string | undefined>;
53
59
  /**
54
60
  * Full path of the app directory this config belongs to
55
61
  * @returns {string} the path
@@ -60,6 +66,11 @@ export declare abstract class AppConfig {
60
66
  postData(): Promise<any>;
61
67
  prepareFileData(): any;
62
68
  abstract hasValues: boolean;
69
+ /**
70
+ * Whether .json files are parsed into values; otherwise they are pushed as raw files
71
+ * @returns {boolean} true to parse
72
+ */
73
+ protected parsesJsonValues(): boolean;
63
74
  /** build the JSON used when making requests (usually push) to the API */
64
75
  protected abstract preparePostData(postData: any): Promise<any>;
65
76
  abstract type: ConfigType;
@@ -104,7 +115,9 @@ export declare class AppConfigFunction extends AppConfig {
104
115
  export declare class AppConfigComponent extends AppConfig {
105
116
  hasValues: boolean;
106
117
  type: ConfigType;
118
+ protected parsesJsonValues(): boolean;
107
119
  isRootComponent(): boolean;
120
+ protected contentHash(): Promise<string | undefined>;
108
121
  preparePostData(postData: any): Promise<any>;
109
122
  }
110
123
  export declare class AppConfigAsset extends AppConfigDefault {
@@ -3,8 +3,8 @@ import isEmpty from 'lodash/isEmpty.js';
3
3
  import { detectFilenameMime } from 'mime-detect';
4
4
  import * as fs from 'node:fs/promises';
5
5
  import * as path from 'node:path';
6
- import { bundleFunction, bundleComponent, bundleWorkflow, getLocalInputs, } from '../bundle.js';
7
- import { analyzeFunctionSource, hasKindDiagnostic, workflowStaticConfigError, } from '../function-source-analysis.js';
6
+ import { bundleFunction, bundleComponent, bundleComponentModule, bundleWorkflow, getLocalInputs, } from '../bundle.js';
7
+ import { analyzeFunctionSource, hasKindDiagnostic, readStaticConfig, workflowStaticConfigError, } from '../function-source-analysis.js';
8
8
  import { AllConfigPaths, ConfigType, filePathExistsAsync, hashFile, hashString, } from './index.js';
9
9
  export class IgnoringFileError extends Error {
10
10
  constructor(message) {
@@ -95,6 +95,30 @@ export class AppConfig {
95
95
  async contentHash() {
96
96
  return this.fileHash;
97
97
  }
98
+ /**
99
+ * File hash combined with the app files this file imports, for configs that
100
+ * deploy as a bundle: a change in shared code redeploys them
101
+ * @returns {Promise<string | undefined>} the hash, or undefined until the file compiles
102
+ */
103
+ async bundleHash() {
104
+ if (!this.fileHash) {
105
+ return this.fileHash;
106
+ }
107
+ const appRoot = this.appRoot();
108
+ let inputs;
109
+ try {
110
+ inputs = await getLocalInputs(this.appPath, appRoot);
111
+ }
112
+ catch {
113
+ // Unknown until it compiles: push tries it and reports the error
114
+ return undefined;
115
+ }
116
+ const imports = inputs
117
+ .filter((input) => input !== this.filePath)
118
+ .sort()
119
+ .map((input) => hashFile(path.join(appRoot, input), undefined, input));
120
+ return hashString(this.fileHash, ...imports);
121
+ }
98
122
  /**
99
123
  * Full path of the app directory this config belongs to
100
124
  * @returns {string} the path
@@ -166,7 +190,7 @@ export class AppConfig {
166
190
  if (this.fileData === undefined) {
167
191
  return;
168
192
  }
169
- if (this.hasValues && this.filePath.endsWith('.json')) {
193
+ if (this.parsesJsonValues() && this.filePath.endsWith('.json')) {
170
194
  try {
171
195
  this.values = JSON.parse(this.fileData.toString('utf8'));
172
196
  }
@@ -205,6 +229,13 @@ export class AppConfig {
205
229
  ? { $base64: this.fileData.toString('base64') }
206
230
  : this.fileData?.toString('utf8');
207
231
  }
232
+ /**
233
+ * Whether .json files are parsed into values; otherwise they are pushed as raw files
234
+ * @returns {boolean} true to parse
235
+ */
236
+ parsesJsonValues() {
237
+ return this.hasValues;
238
+ }
208
239
  }
209
240
  export class AppConfigDefault extends AppConfig {
210
241
  hasValues = false;
@@ -280,23 +311,7 @@ export class AppConfigFunction extends AppConfig {
280
311
  // A function deploys as a bundle, so the hash includes the local files it
281
312
  // imports: a change in shared code under functions/lib redeploys it
282
313
  async contentHash() {
283
- if (!this.isRootFunction() || !this.fileHash) {
284
- return this.fileHash;
285
- }
286
- const appRoot = this.appRoot();
287
- let inputs;
288
- try {
289
- inputs = await getLocalInputs(this.appPath, appRoot);
290
- }
291
- catch {
292
- // Unknown until it compiles: push tries it and reports the error
293
- return undefined;
294
- }
295
- const imports = inputs
296
- .filter((input) => input !== this.filePath)
297
- .sort()
298
- .map((input) => hashFile(path.join(appRoot, input), undefined, input));
299
- return hashString(this.fileHash, ...imports);
314
+ return this.isRootFunction() ? this.bundleHash() : this.fileHash;
300
315
  }
301
316
  async preparePostData(postData) {
302
317
  if (this.isRootFunction()) {
@@ -352,10 +367,19 @@ export class AppConfigFunction extends AppConfig {
352
367
  export class AppConfigComponent extends AppConfig {
353
368
  hasValues = true;
354
369
  type = ConfigType.COMPONENT;
370
+ // Only .tsx/.jsx files are components, any .json under components/ is shared source
371
+ parsesJsonValues() {
372
+ return false;
373
+ }
355
374
  isRootComponent() {
356
375
  return (this.isRootConfig('components') &&
357
376
  (this.filePath.endsWith('.jsx') || this.filePath.endsWith('.tsx')));
358
377
  }
378
+ // A component deploys as a bundle, so the hash includes the local files it
379
+ // imports: a change in shared code under components/lib redeploys it
380
+ async contentHash() {
381
+ return this.isRootComponent() ? this.bundleHash() : this.fileHash;
382
+ }
359
383
  async preparePostData(postData) {
360
384
  if (!this.isRootComponent()) {
361
385
  return postData;
@@ -366,19 +390,32 @@ export class AppConfigComponent extends AppConfig {
366
390
  if (!fileData) {
367
391
  return;
368
392
  }
369
- const { code, config } = await bundleComponent(this.filePath);
370
- if (!config) {
371
- throw new IgnoringFileError('Component must export a `config` object.');
393
+ const source = this.fileData?.toString('utf8') ?? '';
394
+ const { config, errors } = readStaticConfig(source, this.filePath);
395
+ if (errors.length > 0) {
396
+ throw new Error(`config must be static metadata: ${errors.join('; ')}`);
397
+ }
398
+ // Checkout payment components still load through swell-js, which evaluates the legacy IIFE
399
+ if (config?.extension) {
400
+ postData.build_file = {
401
+ content_type: 'application/javascript',
402
+ data: await bundleComponent(this.appPath),
403
+ };
404
+ }
405
+ else {
406
+ const { code } = await bundleComponentModule(this.filePath, this.appRoot());
407
+ // The CDN keeps a file's type only through an extension it maps, and
408
+ // it maps application/javascript but not text/javascript. Frames
409
+ // import the bundle as a module, which browsers refuse as text/plain
410
+ postData.build_file = {
411
+ content_type: 'application/javascript',
412
+ data: code,
413
+ };
372
414
  }
373
- // Save the original file and the bundled version
374
415
  postData.file = {
375
416
  data: fileData,
376
417
  };
377
- postData.build_file = {
378
- content_type: 'application/javascript',
379
- data: code,
380
- };
381
- postData.values = config;
418
+ postData.values = config ?? {};
382
419
  }
383
420
  catch (error) {
384
421
  throw new FunctionProcessingError(`Unable to compile component ${this.name}`, error);
@@ -0,0 +1,32 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import type * as http from 'node:http';
3
+ /** Path of component modules on the `swell app dev` proxy */
4
+ export declare const COMPONENT_DEV_PATH = "/.swell/components/";
5
+ /**
6
+ * Whether a file is a component entry: a .tsx or .jsx file directly in components/
7
+ * @param {string} file path relative to the app
8
+ * @returns {boolean} true for a root component
9
+ */
10
+ export declare function isRootComponentFile(file: string): boolean;
11
+ /**
12
+ * Component entries of an app
13
+ * @param {string} appPath app directory
14
+ * @returns {Promise<string[]>} paths relative to the app, sorted
15
+ */
16
+ export declare function listRootComponentFiles(appPath: string): Promise<string[]>;
17
+ /**
18
+ * Components to rebuild after a change: root components bundle their shared
19
+ * code, so a change under components/ outside the root rebuilds all of them
20
+ * @param {string} appPath app directory
21
+ * @param {string} changedFile changed file, relative to the app
22
+ * @returns {Promise<string[]>} root components to push again
23
+ */
24
+ export declare function componentsToRebuild(appPath: string, changedFile: string): Promise<string[]>;
25
+ /**
26
+ * Builds and serves `/.swell/components/<Name>.js` for `swell app dev`
27
+ * @param {string} appPath app directory
28
+ * @param {string} pathname request path
29
+ * @param {http.ServerResponse} res response to write
30
+ * @returns {Promise<void>} resolves when the response is written
31
+ */
32
+ export declare function serveComponentModule(appPath: string, pathname: string, res: http.ServerResponse): Promise<void>;
@@ -0,0 +1,81 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { bundleComponentModule } from '../bundle.js';
4
+ import { getNameFromFilePath } from './index.js';
5
+ /** Path of component modules on the `swell app dev` proxy */
6
+ export const COMPONENT_DEV_PATH = '/.swell/components/';
7
+ const COMPONENT_EXTENSIONS = new Set(['.jsx', '.tsx']);
8
+ /**
9
+ * Whether a file is a component entry: a .tsx or .jsx file directly in components/
10
+ * @param {string} file path relative to the app
11
+ * @returns {boolean} true for a root component
12
+ */
13
+ export function isRootComponentFile(file) {
14
+ const parts = file.split('/');
15
+ return (parts.length === 2 &&
16
+ parts[0] === 'components' &&
17
+ COMPONENT_EXTENSIONS.has(path.extname(parts[1])));
18
+ }
19
+ /**
20
+ * Component entries of an app
21
+ * @param {string} appPath app directory
22
+ * @returns {Promise<string[]>} paths relative to the app, sorted
23
+ */
24
+ export async function listRootComponentFiles(appPath) {
25
+ let names;
26
+ try {
27
+ names = await fs.readdir(path.join(appPath, 'components'));
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ return names
33
+ .map((name) => `components/${name}`)
34
+ .filter((file) => isRootComponentFile(file))
35
+ .sort();
36
+ }
37
+ /**
38
+ * Components to rebuild after a change: root components bundle their shared
39
+ * code, so a change under components/ outside the root rebuilds all of them
40
+ * @param {string} appPath app directory
41
+ * @param {string} changedFile changed file, relative to the app
42
+ * @returns {Promise<string[]>} root components to push again
43
+ */
44
+ export async function componentsToRebuild(appPath, changedFile) {
45
+ if (!changedFile.startsWith('components/') ||
46
+ isRootComponentFile(changedFile)) {
47
+ return [];
48
+ }
49
+ return listRootComponentFiles(appPath);
50
+ }
51
+ /**
52
+ * Builds and serves `/.swell/components/<Name>.js` for `swell app dev`
53
+ * @param {string} appPath app directory
54
+ * @param {string} pathname request path
55
+ * @param {http.ServerResponse} res response to write
56
+ * @returns {Promise<void>} resolves when the response is written
57
+ */
58
+ export async function serveComponentModule(appPath, pathname, res) {
59
+ const headers = {
60
+ 'Access-Control-Allow-Origin': '*',
61
+ 'Cache-Control': 'no-store',
62
+ };
63
+ const name = /^\/\.swell\/components\/([\w-]+)\.js$/.exec(pathname)?.[1];
64
+ const files = name ? await listRootComponentFiles(appPath) : [];
65
+ const file = files.find((entry) => getNameFromFilePath(entry) === name);
66
+ if (!file) {
67
+ res.writeHead(404, headers).end('Component not found');
68
+ return;
69
+ }
70
+ try {
71
+ const { code } = await bundleComponentModule(file, appPath);
72
+ res
73
+ .writeHead(200, { ...headers, 'Content-Type': 'text/javascript' })
74
+ .end(code);
75
+ }
76
+ catch (error) {
77
+ res
78
+ .writeHead(500, { ...headers, 'Content-Type': 'text/plain' })
79
+ .end(error.message);
80
+ }
81
+ }
@@ -205,6 +205,7 @@ export declare function writeJsonFile(file: string, data?: any): Promise<void>;
205
205
  export declare function hashFile(filePath: string, fileContents?: Buffer, relativePath?: string): string;
206
206
  export declare function hashString(...contents: (Buffer | string)[]): string;
207
207
  export declare function appAssetImage(appPath: string, fileName: string): Promise<string | undefined>;
208
+ export declare function getNameFromFilePath(filePath: string): string;
208
209
  export declare function appConfigFromFile(filePath: string, configType: ConfigType, appPath: string): Promise<AppConfig>;
209
210
  export declare function findAppConfig(app: App, filePath: string, configType: ConfigType): AppConfig | undefined;
210
211
  interface BatchAppFiles {
@@ -52,9 +52,11 @@ export var ConfigType;
52
52
  ConfigType["THEME"] = "theme";
53
53
  ConfigType["WEBHOOK"] = "webhook";
54
54
  })(ConfigType || (ConfigType = {}));
55
- // Models and content should come first in order
55
+ // Models first; then components, which content and settings fields reference
56
+ // by name; then content
56
57
  const ConfigTypeBatchOrder = [
57
58
  ConfigType.MODEL,
59
+ ConfigType.COMPONENT,
58
60
  ConfigType.CONTENT,
59
61
  ConfigType.ASSET,
60
62
  ConfigType.FUNCTION,
@@ -63,7 +65,6 @@ const ConfigTypeBatchOrder = [
63
65
  ConfigType.WEBHOOK,
64
66
  ConfigType.THEME,
65
67
  ConfigType.FRONTEND,
66
- ConfigType.COMPONENT,
67
68
  ConfigType.FILE,
68
69
  ];
69
70
  // All available configs
@@ -89,6 +90,7 @@ const StandardConfigTypes = [
89
90
  'NOTIFICATION',
90
91
  'SETTING',
91
92
  'WEBHOOK',
93
+ 'COMPONENT',
92
94
  ];
93
95
  // Configs available for storefront app types
94
96
  export const StorefrontConfigTypes = [
@@ -359,7 +361,7 @@ export async function appAssetImage(appPath, fileName) {
359
361
  }
360
362
  }
361
363
  }
362
- function getNameFromFilePath(filePath) {
364
+ export function getNameFromFilePath(filePath) {
363
365
  return path.parse(filePath).name.replaceAll('_', '-');
364
366
  }
365
367
  export async function appConfigFromFile(filePath, configType, appPath) {
@@ -9,4 +9,24 @@ import type { FunctionSourceAnalysis } from './function-source-analysis.js';
9
9
  export declare function getLocalInputs(entryPoint: string, rootDir: string): Promise<string[]>;
10
10
  export declare function bundleFunction(filePath: string): Promise<any>;
11
11
  export declare function bundleWorkflow(filePath: string, analysis: FunctionSourceAnalysis): Promise<any>;
12
- export declare function bundleComponent(filePath: string): Promise<any>;
12
+ /**
13
+ * Legacy build for checkout payment components, which swell-js evaluates as an
14
+ * IIFE exposing `Component.default`, `Component.config` and `Component.preact`.
15
+ * @param {string} filePath absolute path of the component file
16
+ * @returns {Promise<string>} the bundle code
17
+ */
18
+ export declare function bundleComponent(filePath: string): Promise<string>;
19
+ export interface ComponentModuleBuild {
20
+ /** ES module exporting mount, update and unmount */
21
+ code: string;
22
+ /** App files the component imports, relative to the app root */
23
+ inputs: string[];
24
+ }
25
+ /**
26
+ * Builds a component into the module the component frame loads: generated
27
+ * mount, update and unmount functions render its default export with Preact.
28
+ * @param {string} filePath component file, relative to appRoot
29
+ * @param {string} appRoot app directory
30
+ * @returns {Promise<ComponentModuleBuild>} the module code and the app files it includes
31
+ */
32
+ export declare function bundleComponentModule(filePath: string, appRoot: string): Promise<ComponentModuleBuild>;
@@ -84,6 +84,12 @@ export async function bundleWorkflow(filePath, analysis) {
84
84
  throw new Error(`Unable to compile workflow "${filePath}": ${error.message}`, { cause: error });
85
85
  }
86
86
  }
87
+ /**
88
+ * Legacy build for checkout payment components, which swell-js evaluates as an
89
+ * IIFE exposing `Component.default`, `Component.config` and `Component.preact`.
90
+ * @param {string} filePath absolute path of the component file
91
+ * @returns {Promise<string>} the bundle code
92
+ */
87
93
  export async function bundleComponent(filePath) {
88
94
  try {
89
95
  const content = await fs.readFile(filePath, 'utf8');
@@ -113,11 +119,7 @@ export async function bundleComponent(filePath) {
113
119
  write: false,
114
120
  logLevel: 'silent',
115
121
  });
116
- const { outputFiles: [{ text: code }], } = buildResult;
117
- // eslint-disable-next-line no-new-func
118
- const evalFn = new Function(`${code}\nreturn { ...Component }`);
119
- const { config } = evalFn();
120
- return { code, config };
122
+ return buildResult.outputFiles[0].text;
121
123
  }
122
124
  catch (error) {
123
125
  throw new Error(`Unable to compile component "${filePath}": ${error.message}`, { cause: error });
@@ -435,3 +437,84 @@ function replaceModuleExports(code) {
435
437
  .replaceAll(/^module\.exports\b/gm, 'var moduleExports')
436
438
  .replaceAll(/\bmodule\.exports\b/gm, 'moduleExports');
437
439
  }
440
+ const COMPONENT_ENTRY = 'swell-component-entry.js';
441
+ // Components render with Preact; React imports in third-party libraries resolve to its compat layer
442
+ const COMPONENT_ALIASES = {
443
+ react: 'preact/compat',
444
+ 'react-dom': 'preact/compat',
445
+ };
446
+ // The nearest tsconfig.json overrides esbuild's jsx options per file, and
447
+ // apps may still say "jsx": "react". A pragma wins over both, so the app's
448
+ // own .jsx/.tsx files always compile with Preact's automatic runtime, while
449
+ // the rest of its tsconfig (paths, baseUrl) still applies.
450
+ const preactJsxPlugin = {
451
+ name: 'swell-preact-jsx',
452
+ setup(build) {
453
+ build.onLoad({ filter: /\.[jt]sx$/ }, async (args) => {
454
+ if (args.path.split(path.sep).includes('node_modules')) {
455
+ return;
456
+ }
457
+ const source = await fs.readFile(args.path, 'utf8');
458
+ return {
459
+ contents: `/** @jsxRuntime automatic @jsxImportSource preact */ ${source}`,
460
+ loader: args.path.endsWith('.tsx') ? 'tsx' : 'jsx',
461
+ };
462
+ });
463
+ },
464
+ };
465
+ /**
466
+ * Builds a component into the module the component frame loads: generated
467
+ * mount, update and unmount functions render its default export with Preact.
468
+ * @param {string} filePath component file, relative to appRoot
469
+ * @param {string} appRoot app directory
470
+ * @returns {Promise<ComponentModuleBuild>} the module code and the app files it includes
471
+ */
472
+ export async function bundleComponentModule(filePath, appRoot) {
473
+ const entry = `./${filePath.split(path.sep).join('/')}`;
474
+ try {
475
+ const { metafile, outputFiles } = await esbuild.build({
476
+ absWorkingDir: appRoot,
477
+ alias: COMPONENT_ALIASES,
478
+ bundle: true,
479
+ format: 'esm',
480
+ jsx: 'automatic',
481
+ jsxImportSource: 'preact',
482
+ logLevel: 'silent',
483
+ metafile: true,
484
+ minify: true,
485
+ platform: 'browser',
486
+ plugins: [preactJsxPlugin],
487
+ stdin: {
488
+ contents: [
489
+ "import { h, render } from 'preact';",
490
+ `import Component from ${JSON.stringify(entry)};`,
491
+ 'export function mount(root, props) { render(h(Component, props), root); }',
492
+ 'export function update(root, props) { render(h(Component, props), root); }',
493
+ 'export function unmount(root) { render(null, root); }',
494
+ ].join('\n'),
495
+ loader: 'js',
496
+ resolveDir: appRoot,
497
+ sourcefile: COMPONENT_ENTRY,
498
+ },
499
+ target: 'es2020',
500
+ write: false,
501
+ });
502
+ const inputs = Object.keys(metafile.inputs).filter((input) => !input.includes('node_modules/') &&
503
+ !input.startsWith('<') &&
504
+ input !== COMPONENT_ENTRY);
505
+ return { code: outputFiles[0].text, inputs };
506
+ }
507
+ catch (error) {
508
+ const nonJs = /Cannot import "([^"]+)" into a JavaScript file|No loader is configured for "[^"]+" files: (\S+)/.exec(error.message);
509
+ let reason = error.message;
510
+ if (/Could not resolve "preact(?:\/[^"]*)?"/.test(error.message)) {
511
+ reason = 'install preact in the app (npm install preact)';
512
+ }
513
+ else if (nonJs) {
514
+ reason = `components cannot import CSS or other non-JavaScript files yet (${nonJs[1] ?? nonJs[2]}); use inline styles or a <style> element`;
515
+ }
516
+ throw new Error(`Unable to compile component "${filePath}": ${reason}`, {
517
+ cause: error,
518
+ });
519
+ }
520
+ }
@@ -0,0 +1,18 @@
1
+ export declare const PREACT_VERSION = "^10.28.0";
2
+ export declare const APPS_SDK_VERSION = "^2.0.0-alpha.6";
3
+ export declare const toComponentName: (value: string) => string;
4
+ export declare function getComponentTemplate(name: string, description: string): string;
5
+ export interface ComponentSetupResult {
6
+ changed: string[];
7
+ dependenciesAdded: boolean;
8
+ warnings: string[];
9
+ }
10
+ /**
11
+ * Adds preact and @swell/apps-sdk to package.json, and gives components their
12
+ * own tsconfig, excluded from the root one and checked by the typecheck script.
13
+ * Never overwrites values the app already has, except a 2.x SDK from before
14
+ * components.
15
+ * @param {string} appPath app directory
16
+ * @returns {Promise<ComponentSetupResult>} files changed and warnings to show
17
+ */
18
+ export declare function setupComponentSupport(appPath: string): Promise<ComponentSetupResult>;
@@ -0,0 +1,186 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as semver from 'semver';
4
+ export const PREACT_VERSION = '^10.28.0';
5
+ export const APPS_SDK_VERSION = '^2.0.0-alpha.6';
6
+ // The first release with the ./components entry
7
+ const COMPONENTS_SDK = semver.minVersion(APPS_SDK_VERSION);
8
+ // Example: 'color picker' => 'ColorPicker'
9
+ export const toComponentName = (value) => {
10
+ const name = value
11
+ .split('.')[0]
12
+ .split(/[^\dA-Za-z]+/)
13
+ .filter(Boolean)
14
+ .map((part) => part[0].toUpperCase() + part.slice(1))
15
+ .join('');
16
+ // A component name is also its function name, so it must start with a letter
17
+ return /^[A-Za-z]/.test(name) ? name : '';
18
+ };
19
+ export function getComponentTemplate(name, description) {
20
+ return `import type { ComponentProps } from '@swell/apps-sdk/components';
21
+
22
+ export const config = {
23
+ description: ${JSON.stringify(description)},
24
+ };
25
+
26
+ export default function ${name}({ value, setValue, readonly }: ComponentProps<string>) {
27
+ return (
28
+ <input
29
+ value={value ?? ''}
30
+ disabled={readonly}
31
+ onInput={(event) => setValue(event.currentTarget.value)}
32
+ />
33
+ );
34
+ }
35
+ `;
36
+ }
37
+ // undefined when the file is missing, null when it is not valid JSON
38
+ async function readJson(file) {
39
+ let text;
40
+ try {
41
+ text = await fs.readFile(file, 'utf8');
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ try {
47
+ return JSON.parse(text);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ const writeJson = (file, value) => fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
54
+ // Appended to the app's typecheck script, as the scaffold does for test/ and frontend/
55
+ const TYPECHECK_COMPONENTS = ' && ([ ! -f components/tsconfig.json ] || tsc --noEmit -p components)';
56
+ /**
57
+ * Components render in the browser with Preact, while the root tsconfig
58
+ * checks functions with the worker libs, which conflict with the DOM ones.
59
+ * Components get their own config instead.
60
+ * @param {boolean} extendsRoot whether the app has a root tsconfig to extend
61
+ * @returns {object} the contents of components/tsconfig.json
62
+ */
63
+ function getComponentsTsconfig(extendsRoot) {
64
+ const compilerOptions = {
65
+ jsx: 'react-jsx',
66
+ jsxImportSource: 'preact',
67
+ lib: ['esnext', 'dom', 'dom.iterable'],
68
+ types: [],
69
+ // Relative to this file when no baseUrl is set
70
+ paths: {
71
+ react: ['../node_modules/preact/compat'],
72
+ 'react-dom': ['../node_modules/preact/compat'],
73
+ },
74
+ };
75
+ if (extendsRoot) {
76
+ return {
77
+ extends: '../tsconfig.json',
78
+ compilerOptions,
79
+ include: ['**/*'],
80
+ // The root config excludes components, which would exclude every file here
81
+ exclude: [],
82
+ };
83
+ }
84
+ return {
85
+ compilerOptions: {
86
+ ...compilerOptions,
87
+ module: 'esnext',
88
+ moduleResolution: 'bundler',
89
+ target: 'esnext',
90
+ noEmit: true,
91
+ },
92
+ include: ['**/*'],
93
+ exclude: [],
94
+ };
95
+ }
96
+ /**
97
+ * Adds preact and @swell/apps-sdk to package.json, and gives components their
98
+ * own tsconfig, excluded from the root one and checked by the typecheck script.
99
+ * Never overwrites values the app already has, except a 2.x SDK from before
100
+ * components.
101
+ * @param {string} appPath app directory
102
+ * @returns {Promise<ComponentSetupResult>} files changed and warnings to show
103
+ */
104
+ export async function setupComponentSupport(appPath) {
105
+ const result = {
106
+ changed: [],
107
+ dependenciesAdded: false,
108
+ warnings: [],
109
+ };
110
+ const packagePath = path.join(appPath, 'package.json');
111
+ const pkg = await readJson(packagePath);
112
+ if (pkg) {
113
+ if (!pkg.dependencies?.preact) {
114
+ pkg.dependencies = { ...pkg.dependencies, preact: PREACT_VERSION };
115
+ result.dependenciesAdded = true;
116
+ }
117
+ const sdkSection = ['dependencies', 'devDependencies'].find((section) => pkg[section]?.['@swell/apps-sdk']);
118
+ if (sdkSection) {
119
+ const range = pkg[sdkSection]['@swell/apps-sdk'];
120
+ const min = semver.validRange(range) ? semver.minVersion(range) : null;
121
+ if (min && semver.lt(min, COMPONENTS_SDK)) {
122
+ // `swell create tests` pins an earlier 2.x; 1.x is another SDK the app may use
123
+ if (semver.major(min) === 2) {
124
+ pkg[sdkSection]['@swell/apps-sdk'] = APPS_SDK_VERSION;
125
+ result.dependenciesAdded = true;
126
+ }
127
+ else {
128
+ result.warnings.push(`@swell/apps-sdk ${range} has no components: install @swell/apps-sdk@${APPS_SDK_VERSION} to type them.`);
129
+ }
130
+ }
131
+ }
132
+ else {
133
+ pkg.devDependencies = {
134
+ ...pkg.devDependencies,
135
+ '@swell/apps-sdk': APPS_SDK_VERSION,
136
+ };
137
+ result.dependenciesAdded = true;
138
+ }
139
+ const typecheck = pkg.scripts?.typecheck;
140
+ const typecheckUpdated = typeof typecheck === 'string' && !typecheck.includes('components');
141
+ if (typecheckUpdated) {
142
+ pkg.scripts.typecheck = typecheck + TYPECHECK_COMPONENTS;
143
+ }
144
+ if (result.dependenciesAdded || typecheckUpdated) {
145
+ await writeJson(packagePath, pkg);
146
+ result.changed.push('package.json');
147
+ }
148
+ }
149
+ else {
150
+ result.warnings.push(pkg === undefined
151
+ ? 'package.json not found: run `npm init`, then `npm install preact` and `npm install -D @swell/apps-sdk`.'
152
+ : 'package.json could not be parsed: add preact to dependencies and @swell/apps-sdk to devDependencies.');
153
+ }
154
+ const tsconfigPath = path.join(appPath, 'tsconfig.json');
155
+ const tsconfig = await readJson(tsconfigPath);
156
+ if (tsconfig) {
157
+ const { exclude } = tsconfig;
158
+ if (!Array.isArray(exclude) || !exclude.includes('components')) {
159
+ // An explicit exclude replaces TypeScript's default one, so keep node_modules
160
+ tsconfig.exclude = [
161
+ ...(Array.isArray(exclude) ? exclude : ['node_modules']),
162
+ 'components',
163
+ ];
164
+ await writeJson(tsconfigPath, tsconfig);
165
+ result.changed.push('tsconfig.json');
166
+ }
167
+ }
168
+ const componentsTsconfigPath = path.join(appPath, 'components', 'tsconfig.json');
169
+ await fs.mkdir(path.dirname(componentsTsconfigPath), { recursive: true });
170
+ const created = await fs
171
+ .writeFile(componentsTsconfigPath, `${JSON.stringify(getComponentsTsconfig(Boolean(tsconfig)), null, 2)}\n`, { flag: 'wx' })
172
+ .then(() => true, (error) => {
173
+ if (error.code !== 'EEXIST')
174
+ throw error;
175
+ return false;
176
+ });
177
+ if (created) {
178
+ result.changed.push('components/tsconfig.json');
179
+ }
180
+ if (tsconfig === null) {
181
+ result.warnings.push(created
182
+ ? 'tsconfig.json could not be parsed: add "components" to its "exclude". components/tsconfig.json was created without "extends".'
183
+ : 'tsconfig.json could not be parsed: add "components" to its "exclude".');
184
+ }
185
+ return result;
186
+ }
@@ -13,7 +13,7 @@ interface ContentViewProps {
13
13
  tabs: [];
14
14
  }
15
15
  type ContentView = 'edit' | 'list' | 'new';
16
- type ContentType = 'asset' | 'basic_html' | 'boolean' | 'category_lookup' | 'checkbox' | 'checkboxes' | 'collection' | 'color' | 'color_scheme' | 'color_scheme_group' | 'currency' | 'customer_lookup' | 'date' | 'datetime' | 'document' | 'dropdown' | 'email' | 'field_group' | 'font_family' | 'generic_lookup' | 'html' | 'icon' | 'image' | 'long_text' | 'lookup' | 'markdown' | 'number' | 'percent' | 'phone' | 'product_lookup' | 'radio' | 'rich_html' | 'rich_text' | 'select' | 'short_text' | 'slider' | 'slug' | 'tags' | 'text' | 'textarea' | 'time' | 'toggle' | 'url' | 'variant_lookup' | 'video';
16
+ type ContentType = 'asset' | 'basic_html' | 'boolean' | 'category_lookup' | 'checkbox' | 'checkboxes' | 'collection' | 'color' | 'color_scheme' | 'color_scheme_group' | 'component' | 'currency' | 'customer_lookup' | 'date' | 'datetime' | 'document' | 'dropdown' | 'email' | 'field_group' | 'font_family' | 'generic_lookup' | 'html' | 'icon' | 'image' | 'long_text' | 'lookup' | 'markdown' | 'number' | 'percent' | 'phone' | 'product_lookup' | 'radio' | 'rich_html' | 'rich_text' | 'select' | 'short_text' | 'slider' | 'slug' | 'tags' | 'text' | 'textarea' | 'time' | 'toggle' | 'url' | 'variant_lookup' | 'video';
17
17
  interface ContentFieldProps {
18
18
  id: string;
19
19
  label: string;
@@ -9,7 +9,7 @@ interface SettingFieldProps {
9
9
  label: string;
10
10
  type?: SettingFieldType;
11
11
  }
12
- type SettingFieldType = 'asset' | 'basic_html' | 'boolean' | 'category_lookup' | 'checkbox' | 'checkboxes' | 'collection' | 'color' | 'color_scheme' | 'color_scheme_group' | 'currency' | 'customer_lookup' | 'date' | 'datetime' | 'document' | 'dropdown' | 'email' | 'field_group' | 'font_family' | 'generic_lookup' | 'html' | 'icon' | 'image' | 'long_text' | 'lookup' | 'markdown' | 'number' | 'percent' | 'phone' | 'product_lookup' | 'radio' | 'rich_html' | 'rich_text' | 'select' | 'short_text' | 'slider' | 'slug' | 'tags' | 'text' | 'textarea' | 'time' | 'toggle' | 'url' | 'variant_lookup' | 'video';
12
+ type SettingFieldType = 'asset' | 'basic_html' | 'boolean' | 'category_lookup' | 'checkbox' | 'checkboxes' | 'collection' | 'color' | 'color_scheme' | 'color_scheme_group' | 'component' | 'currency' | 'customer_lookup' | 'date' | 'datetime' | 'document' | 'dropdown' | 'email' | 'field_group' | 'font_family' | 'generic_lookup' | 'html' | 'icon' | 'image' | 'long_text' | 'lookup' | 'markdown' | 'number' | 'percent' | 'phone' | 'product_lookup' | 'radio' | 'rich_html' | 'rich_text' | 'select' | 'short_text' | 'slider' | 'slug' | 'tags' | 'text' | 'textarea' | 'time' | 'toggle' | 'url' | 'variant_lookup' | 'video';
13
13
  export declare function parseFields(fields: string[]): SettingFieldProps[];
14
14
  export declare const toSettingLabel: (value: string) => string;
15
15
  export {};
@@ -14,3 +14,14 @@ export declare function analyzeFunctionSourceText(sourceText: string, filePath?:
14
14
  export declare function getFunctionTriggers(config: Record<string, unknown>): string[];
15
15
  export declare function hasKindDiagnostic(analysis: FunctionSourceAnalysis): boolean;
16
16
  export declare function workflowStaticConfigError(): Error;
17
+ export interface StaticConfig {
18
+ config?: Record<string, unknown>;
19
+ errors: string[];
20
+ }
21
+ /**
22
+ * Reads `export const config = {...}` as static JSON-like data without running the module.
23
+ * @param {string} sourceText source code of the file
24
+ * @param {string} filePath file name, used to pick the parser (TS, TSX, JS or JSX)
25
+ * @returns {StaticConfig} the config when present and static, otherwise the errors
26
+ */
27
+ export declare function readStaticConfig(sourceText: string, filePath: string): StaticConfig;
@@ -76,6 +76,39 @@ export function hasKindDiagnostic(analysis) {
76
76
  export function workflowStaticConfigError() {
77
77
  return new Error('Workflow configs must export a static object literal so the CLI can choose the workflow bundling path before compiling.');
78
78
  }
79
+ /**
80
+ * Reads `export const config = {...}` as static JSON-like data without running the module.
81
+ * @param {string} sourceText source code of the file
82
+ * @param {string} filePath file name, used to pick the parser (TS, TSX, JS or JSX)
83
+ * @returns {StaticConfig} the config when present and static, otherwise the errors
84
+ */
85
+ export function readStaticConfig(sourceText, filePath) {
86
+ const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, scriptKind(filePath));
87
+ const configExport = findConfigExport(sourceFile);
88
+ if (!configExport) {
89
+ return { errors: [] };
90
+ }
91
+ if (!configExport.initializer) {
92
+ return { errors: ['config must be initialized with an object literal.'] };
93
+ }
94
+ const result = evaluateStatic(configExport.initializer);
95
+ if (result.errors.length > 0) {
96
+ return { errors: result.errors };
97
+ }
98
+ if (!isRecord(result.value)) {
99
+ return { errors: ['config must be an object literal.'] };
100
+ }
101
+ return { config: result.value, errors: [] };
102
+ }
103
+ function scriptKind(filePath) {
104
+ if (filePath.endsWith('.tsx'))
105
+ return ts.ScriptKind.TSX;
106
+ if (filePath.endsWith('.jsx'))
107
+ return ts.ScriptKind.JSX;
108
+ if (filePath.endsWith('.ts'))
109
+ return ts.ScriptKind.TS;
110
+ return ts.ScriptKind.JS;
111
+ }
79
112
  function findConfigExport(sourceFile) {
80
113
  const localVariables = new Map();
81
114
  let exportedConfigLocalName;
@@ -107,6 +107,10 @@ class SwellRequest {
107
107
  this.id = req.headers.get('Swell-Request-ID') || this.logParams?.req_id;
108
108
  // Check if the request is from a local development environment
109
109
  this.isLocalDev = req.headers.get('Swell-Local-Dev') === 'true';
110
+ // Context of a call from an app component (Swell-Context). Only the platform
111
+ // sets this header; verify it with verifySwellContext from @swell/apps-sdk
112
+ // when a function needs cryptographic proof
113
+ this.swellContext = this.parseSwellContext(req.headers.get('Swell-Context'));
110
114
  // Swell client
111
115
  this.swell = new SwellAPI(this, context);
112
116
  // URL of the original request
@@ -160,6 +164,33 @@ class SwellRequest {
160
164
  return {};
161
165
  }
162
166
  }
167
+ parseSwellContext(token) {
168
+ const payload = token?.split('.')[1];
169
+ if (!payload) {
170
+ return null;
171
+ }
172
+ let claims;
173
+ try {
174
+ const binary = atob(payload.replaceAll('-', '+').replaceAll('_', '/'));
175
+ claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))));
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ if (!claims || typeof claims !== 'object') {
181
+ return null;
182
+ }
183
+ return {
184
+ appId: claims.app_id,
185
+ installationId: claims.installation_id,
186
+ storeId: claims.store_id,
187
+ storeUser: claims.admin?.user_id
188
+ ? { storeId: claims.store_id, userId: claims.admin.user_id }
189
+ : null,
190
+ storefrontId: claims.storefront_id ?? undefined,
191
+ surface: claims.surface ?? undefined,
192
+ };
193
+ }
163
194
  log(level, ...line) {
164
195
  originalConsole[level]?.(...line);
165
196
  this._logs.push({
@@ -6,6 +6,7 @@ import * as path from 'node:path';
6
6
  import Stream from 'node:stream';
7
7
  import ora from 'ora';
8
8
  import { default as swellConfig } from './lib/app-config.js';
9
+ import { componentsToRebuild } from './lib/apps/components.js';
9
10
  import { buildFrontendPackage, getManagedFrontendFramework, } from './lib/apps/frontend-package.js';
10
11
  import { warnFrontendRouteCollisions } from './lib/apps/frontend-routes.js';
11
12
  import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashFile, hashString, isPathDirectory, toPosixPath, } from './lib/apps/index.js';
@@ -714,6 +715,16 @@ export class PushAppCommand extends RemoteAppCommand {
714
715
  appConfig: config,
715
716
  configFile,
716
717
  });
718
+ // Root components bundle their shared code, so push them again too
719
+ const componentFiles = await componentsToRebuild(this.appPath, configFile);
720
+ const componentConfigs = await Promise.all(componentFiles.map((file) => appConfigFromFile(file, ConfigType.COMPONENT, this.appPath)));
721
+ for (const appConfig of componentConfigs) {
722
+ this.watchingChangeQueue.set(appConfig.filePath, {
723
+ action: 'push',
724
+ appConfig,
725
+ configFile: appConfig.filePath,
726
+ });
727
+ }
717
728
  break;
718
729
  }
719
730
  }
@@ -1652,6 +1652,70 @@
1652
1652
  "app.js"
1653
1653
  ]
1654
1654
  },
1655
+ "create:component": {
1656
+ "aliases": [],
1657
+ "args": {
1658
+ "name": {
1659
+ "default": "",
1660
+ "description": "Component name (e.g., ColorPicker)",
1661
+ "name": "name"
1662
+ }
1663
+ },
1664
+ "description": "Create an app component in the components folder.",
1665
+ "examples": [
1666
+ "$ swell create component",
1667
+ "$ swell create component ColorPicker -d \"Brand color picker\" -y"
1668
+ ],
1669
+ "flags": {
1670
+ "app-path": {
1671
+ "description": "Path to your app directory",
1672
+ "name": "app-path",
1673
+ "hasDynamicHelp": false,
1674
+ "multiple": false,
1675
+ "type": "option"
1676
+ },
1677
+ "description": {
1678
+ "char": "d",
1679
+ "description": "Description",
1680
+ "name": "description",
1681
+ "default": "",
1682
+ "hasDynamicHelp": false,
1683
+ "multiple": false,
1684
+ "type": "option"
1685
+ },
1686
+ "overwrite": {
1687
+ "description": "Overwrite existing file",
1688
+ "name": "overwrite",
1689
+ "allowNo": false,
1690
+ "type": "boolean"
1691
+ },
1692
+ "yes": {
1693
+ "char": "y",
1694
+ "description": "Skip prompts, require all arguments",
1695
+ "name": "yes",
1696
+ "allowNo": false,
1697
+ "type": "boolean"
1698
+ }
1699
+ },
1700
+ "hasDynamicHelp": false,
1701
+ "hiddenAliases": [],
1702
+ "id": "create:component",
1703
+ "pluginAlias": "@swell/cli",
1704
+ "pluginName": "@swell/cli",
1705
+ "pluginType": "core",
1706
+ "strict": true,
1707
+ "summary": "Create an app component in the components folder.",
1708
+ "helpMeta": {
1709
+ "usageDirect": "<name> [-d <description>] -y"
1710
+ },
1711
+ "isESM": true,
1712
+ "relativePath": [
1713
+ "dist",
1714
+ "commands",
1715
+ "create",
1716
+ "component.js"
1717
+ ]
1718
+ },
1655
1719
  "create:content": {
1656
1720
  "aliases": [],
1657
1721
  "args": {
@@ -1978,7 +2042,7 @@
1978
2042
  "create": {
1979
2043
  "aliases": [],
1980
2044
  "args": {},
1981
- "description": "Scaffold apps, models, functions, and notifications.",
2045
+ "description": "Scaffold apps, models, functions, components, and notifications.",
1982
2046
  "examples": [
1983
2047
  "<%= config.bin %> <%= command.id %>"
1984
2048
  ],
@@ -3606,5 +3670,5 @@
3606
3670
  ]
3607
3671
  }
3608
3672
  },
3609
- "version": "2.9.26"
3673
+ "version": "2.9.27"
3610
3674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swell/cli",
3
- "version": "2.9.26",
3
+ "version": "2.9.27",
4
4
  "type": "module",
5
5
  "description": "Swell's command line interface/utility",
6
6
  "keywords": [
@@ -80,9 +80,11 @@
80
80
  "eslint-config-prettier": "9.0.0",
81
81
  "eslint-plugin-prettier": "5.0.1",
82
82
  "eslint-plugin-unicorn": "48.0.1",
83
+ "happy-dom": "20.14.5",
83
84
  "mocha": "11.7.6",
84
85
  "nock": "13.3.6",
85
86
  "oclif": "4.0.3",
87
+ "preact": "10.28.0",
86
88
  "shx": "0.3.4",
87
89
  "testdouble": "3.20.0",
88
90
  "ts-node": "10.9.1"