@swell/cli 2.9.31 → 2.9.33

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.
Files changed (57) hide show
  1. package/dist/commands/app/action.d.ts +25 -0
  2. package/dist/commands/app/action.js +233 -0
  3. package/dist/commands/app/dev.js +2 -1
  4. package/dist/commands/app/frontend/deploy.d.ts +1 -0
  5. package/dist/commands/app/frontend/deploy.js +4 -0
  6. package/dist/commands/app/frontend/dev.js +4 -2
  7. package/dist/commands/app/push.d.ts +3 -0
  8. package/dist/commands/app/push.js +100 -4
  9. package/dist/commands/app/version.js +3 -0
  10. package/dist/commands/create/function.js +14 -4
  11. package/dist/commands/create/model.js +9 -24
  12. package/dist/commands/create/webhook.js +7 -13
  13. package/dist/commands/inspect/components.d.ts +31 -0
  14. package/dist/commands/inspect/components.js +110 -0
  15. package/dist/commands/inspect/extensions.js +18 -9
  16. package/dist/commands/inspect/frontend.d.ts +37 -0
  17. package/dist/commands/inspect/frontend.js +75 -0
  18. package/dist/commands/inspect/functions.d.ts +11 -1
  19. package/dist/commands/inspect/functions.js +89 -6
  20. package/dist/commands/inspect/index.js +2 -0
  21. package/dist/commands/schema.js +5 -0
  22. package/dist/help/custom-help.js +5 -1
  23. package/dist/inspect-resource-command.d.ts +1 -1
  24. package/dist/inspect-resource-command.js +8 -2
  25. package/dist/lib/api-errors.d.ts +1 -0
  26. package/dist/lib/api-errors.js +28 -0
  27. package/dist/lib/apps/actions.d.ts +45 -0
  28. package/dist/lib/apps/actions.js +114 -0
  29. package/dist/lib/apps/components.d.ts +7 -0
  30. package/dist/lib/apps/components.js +10 -0
  31. package/dist/lib/apps/extension-manifest.d.ts +1 -0
  32. package/dist/lib/apps/extension-manifest.js +43 -0
  33. package/dist/lib/apps/index.d.ts +1 -0
  34. package/dist/lib/apps/index.js +30 -0
  35. package/dist/lib/apps/listing.d.ts +1 -0
  36. package/dist/lib/apps/listing.js +41 -0
  37. package/dist/lib/apps/paths.d.ts +8 -0
  38. package/dist/lib/apps/paths.js +34 -4
  39. package/dist/lib/bundle.js +26 -41
  40. package/dist/lib/create/function.d.ts +1 -1
  41. package/dist/lib/create/function.js +9 -5
  42. package/dist/lib/create/index.js +2 -1
  43. package/dist/lib/create/model.d.ts +7 -3
  44. package/dist/lib/create/model.js +67 -9
  45. package/dist/lib/function-declarations.d.ts +8 -0
  46. package/dist/lib/function-declarations.js +27 -0
  47. package/dist/lib/inspect/extensions.d.ts +4 -3
  48. package/dist/lib/inspect/extensions.js +36 -5
  49. package/dist/lib/swell-function-wrapper.js +13 -5
  50. package/dist/lib/swell-workflow-request.d.ts +34 -0
  51. package/dist/lib/swell-workflow-request.js +58 -0
  52. package/dist/push-app-command.d.ts +2 -1
  53. package/dist/push-app-command.js +43 -22
  54. package/dist/remote-app-command.d.ts +2 -2
  55. package/dist/remote-app-command.js +79 -51
  56. package/oclif.manifest.json +263 -10
  57. package/package.json +2 -1
@@ -0,0 +1,114 @@
1
+ function isFunctionAction(item) {
2
+ return (typeof item?.id === 'string' &&
3
+ typeof item?.function === 'string');
4
+ }
5
+ // The dashboard renders the fields of rows and groups in place
6
+ function flattenFields(fields) {
7
+ if (!Array.isArray(fields)) {
8
+ return [];
9
+ }
10
+ return fields.flatMap((field) => field?.type === 'field_row' || field?.type === 'field_group'
11
+ ? flattenFields(field.fields)
12
+ : [field]);
13
+ }
14
+ function getActionFields(fields) {
15
+ return flattenFields(fields).filter((field) => field?.type === 'action');
16
+ }
17
+ /**
18
+ * The function actions a content config declares.
19
+ *
20
+ * @param values the values of the content config
21
+ * @returns the actions
22
+ */
23
+ export function getContentActions(values) {
24
+ const views = Array.isArray(values?.views) ? values.views : [];
25
+ const actions = [];
26
+ for (const view of views) {
27
+ const source = view.type || (view.id === 'list' ? 'list' : 'record');
28
+ for (const action of [
29
+ ...(view.actions || []),
30
+ ...(view.extra_actions || []),
31
+ ]) {
32
+ if (isFunctionAction(action)) {
33
+ actions.push({ ...pickAction(action), source, view: view.id });
34
+ }
35
+ }
36
+ for (const action of view.bulk_actions || []) {
37
+ if (isFunctionAction(action)) {
38
+ actions.push({
39
+ ...pickAction(action),
40
+ ...(view.query ? { query: view.query } : undefined),
41
+ source: 'bulk',
42
+ view: view.id,
43
+ });
44
+ }
45
+ }
46
+ }
47
+ const fields = [
48
+ ...(values?.fields || []),
49
+ ...views.flatMap((view) => [
50
+ ...(view.fields || []),
51
+ ...(view.tabs || []).flatMap((tab) => tab?.fields || []),
52
+ ]),
53
+ ];
54
+ for (const field of getActionFields(fields)) {
55
+ if (isFunctionAction(field)) {
56
+ actions.push({ ...pickAction(field), source: 'field' });
57
+ }
58
+ }
59
+ return actions;
60
+ }
61
+ /**
62
+ * The function actions a settings config declares.
63
+ *
64
+ * @param values the values of the settings config
65
+ * @param settings the name of the settings config
66
+ * @returns the actions
67
+ */
68
+ export function getSettingsActions(values, settings) {
69
+ const actions = [];
70
+ for (const action of values?.actions || []) {
71
+ if (isFunctionAction(action)) {
72
+ actions.push({ ...pickAction(action), settings, source: 'settings' });
73
+ }
74
+ }
75
+ for (const field of getActionFields(values?.fields)) {
76
+ if (isFunctionAction(field)) {
77
+ actions.push({ ...pickAction(field), settings, source: 'field' });
78
+ }
79
+ }
80
+ return actions;
81
+ }
82
+ /**
83
+ * The ids of the fields of an action's modal, as the platform finds them to
84
+ * pass on only their values.
85
+ *
86
+ * @param modal the modal of the action
87
+ * @returns the field ids
88
+ */
89
+ export function getModalFieldIds(modal) {
90
+ return flattenFields(modal.fields)
91
+ .map((field) => field?.id)
92
+ .filter((id) => typeof id === 'string');
93
+ }
94
+ /**
95
+ * A function name as the platform compares an action's `function` with it.
96
+ *
97
+ * @param name a function name
98
+ * @returns the name in the form the platform compares
99
+ */
100
+ export function functionSlug(name) {
101
+ return name
102
+ .replaceAll(/[^\s\w./:-]/g, '')
103
+ .replaceAll(/[\s./:_-]+/g, '-')
104
+ .replaceAll(/^-+|-+$/g, '')
105
+ .replaceAll(/([a-z])([A-Z])/g, '$1-$2')
106
+ .toLowerCase();
107
+ }
108
+ function pickAction(action) {
109
+ return {
110
+ function: action.function,
111
+ id: action.id,
112
+ ...(action.modal ? { modal: action.modal } : undefined),
113
+ };
114
+ }
@@ -8,6 +8,13 @@ export declare const COMPONENT_DEV_PATH = "/.swell/components/";
8
8
  * @returns {boolean} true for a root component
9
9
  */
10
10
  export declare function isRootComponentFile(file: string): boolean;
11
+ /**
12
+ * Whether a component file has a name the platform cannot serve: component
13
+ * frames load by a name of letters, digits and dashes
14
+ * @param {string} file path relative to the app
15
+ * @returns {boolean} true for a root component that can never load
16
+ */
17
+ export declare function isUnloadableComponentFile(file: string): boolean;
11
18
  /**
12
19
  * Component entries of an app
13
20
  * @param {string} appPath app directory
@@ -16,6 +16,16 @@ export function isRootComponentFile(file) {
16
16
  parts[0] === 'components' &&
17
17
  COMPONENT_EXTENSIONS.has(path.extname(parts[1])));
18
18
  }
19
+ /**
20
+ * Whether a component file has a name the platform cannot serve: component
21
+ * frames load by a name of letters, digits and dashes
22
+ * @param {string} file path relative to the app
23
+ * @returns {boolean} true for a root component that can never load
24
+ */
25
+ export function isUnloadableComponentFile(file) {
26
+ return (isRootComponentFile(file) &&
27
+ !/^[\dA-Za-z-]+$/.test(getNameFromFilePath(file)));
28
+ }
19
29
  /**
20
30
  * Component entries of an app
21
31
  * @param {string} appPath app directory
@@ -0,0 +1 @@
1
+ export declare function extensionManifestWarnings(extensions: unknown): string[];
@@ -0,0 +1,43 @@
1
+ // The fields of an extension in swell.json that the platform reads: the
2
+ // dashboard shows name, description and setting, and reads method (card or
3
+ // another method) and subscriptions of a payment; it shows the icons, and the
4
+ // logo of a card gateway. Checkout itself reads only the settings the
5
+ // merchant activates.
6
+ const READ_FIELDS = {
7
+ payment: [
8
+ 'method',
9
+ 'subscriptions',
10
+ 'method_icon_src',
11
+ 'gateway_icon_src',
12
+ 'gateway_logo_src',
13
+ ],
14
+ shipping: ['carrier_icon_src'],
15
+ tax: [],
16
+ };
17
+ const COMMON_FIELDS = ['id', 'type', 'name', 'description', 'setting'];
18
+ // Declared on the app model, but nothing reads them
19
+ const UNREAD_FIELDS = new Set([
20
+ 'gateway',
21
+ 'carrier',
22
+ 'method_logo_src',
23
+ 'carrier_logo_src',
24
+ ]);
25
+ export function extensionManifestWarnings(extensions) {
26
+ if (!Array.isArray(extensions)) {
27
+ return [];
28
+ }
29
+ return extensions.flatMap((extension) => {
30
+ if (!extension || typeof extension !== 'object') {
31
+ return [];
32
+ }
33
+ const read = new Set([
34
+ ...COMMON_FIELDS,
35
+ ...(READ_FIELDS[extension.type] ?? []),
36
+ ]);
37
+ return Object.keys(extension)
38
+ .filter((field) => !read.has(field))
39
+ .map((field) => UNREAD_FIELDS.has(field)
40
+ ? `swell.json extension "${extension.id}": ${field} has no effect`
41
+ : `swell.json extension "${extension.id}": unknown field ${field}`);
42
+ });
43
+ }
@@ -206,6 +206,7 @@ export declare function hashFile(filePath: string, fileContents?: Buffer, relati
206
206
  export declare function hashString(...contents: (Buffer | string)[]): string;
207
207
  export declare function appAssetImage(appPath: string, fileName: string): Promise<string | undefined>;
208
208
  export declare function getNameFromFilePath(filePath: string): string;
209
+ export declare function kebabCaseFilePath(filePath: string): string | undefined;
209
210
  export declare function appConfigFromFile(filePath: string, configType: ConfigType, appPath: string): Promise<AppConfig>;
210
211
  export declare function findAppConfig(app: App, filePath: string, configType: ConfigType): AppConfig | undefined;
211
212
  interface BatchAppFiles {
@@ -364,6 +364,36 @@ export async function appAssetImage(appPath, fileName) {
364
364
  export function getNameFromFilePath(filePath) {
365
365
  return path.parse(filePath).name.replaceAll('_', '-');
366
366
  }
367
+ // Configs in the root of these directories are named after their file
368
+ const FILE_NAMED_CONFIG_DIRS = new Set([
369
+ AllConfigPaths.COMPONENT,
370
+ AllConfigPaths.CONTENT,
371
+ AllConfigPaths.FUNCTION,
372
+ AllConfigPaths.MODEL,
373
+ AllConfigPaths.NOTIFICATION,
374
+ AllConfigPaths.SETTING,
375
+ AllConfigPaths.WEBHOOK,
376
+ ]);
377
+ // Among components, only .jsx and .tsx files are components; others are
378
+ // modules the components import
379
+ const COMPONENT_FILE_EXTENSIONS = new Set(['.jsx', '.tsx']);
380
+ // The name of a config turns an underscore in its file name into a dash, so
381
+ // the file goes by another name than what it deploys as unless renamed to this
382
+ export function kebabCaseFilePath(filePath) {
383
+ const [dir, file, ...nested] = filePath.split('/');
384
+ if (nested.length > 0 ||
385
+ !FILE_NAMED_CONFIG_DIRS.has(dir) ||
386
+ !file?.includes('_') ||
387
+ (dir === AllConfigPaths.COMPONENT &&
388
+ !COMPONENT_FILE_EXTENSIONS.has(path.extname(file)))) {
389
+ return undefined;
390
+ }
391
+ const name = getNameFromFilePath(file);
392
+ if (name.startsWith('-')) {
393
+ return undefined;
394
+ }
395
+ return `${dir}/${name}${path.extname(file)}`;
396
+ }
367
397
  export async function appConfigFromFile(filePath, configType, appPath) {
368
398
  const name = getNameFromFilePath(filePath);
369
399
  // Notification basenames must not contain dots: the inspect identifier
@@ -0,0 +1 @@
1
+ export declare function warnListingSources(appPath: string, config: Record<string, any>, warn: (message: string) => void): void;
@@ -0,0 +1,41 @@
1
+ import { statSync } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ const LISTING_SOURCE_FIELDS = ['logo_src', 'preview_src', 'preview_mobile_src'];
4
+ const LISTING_SOURCE_LISTS = ['images', 'highlights'];
5
+ // The listing takes its full description from DESCRIPTION.md and each image
6
+ // from the asset whose path from the app root is the swell.json value as
7
+ // written, so values that point elsewhere do nothing. The platform takes an
8
+ // empty value as unset.
9
+ export function warnListingSources(appPath, config, warn) {
10
+ if (config.full_description !== undefined) {
11
+ warn('swell.json sets full_description, which push does not apply: the full description is the content of DESCRIPTION.md at the app root.');
12
+ }
13
+ const sources = [
14
+ ...LISTING_SOURCE_FIELDS.map((field) => [
15
+ field,
16
+ config[field],
17
+ ]),
18
+ ...LISTING_SOURCE_LISTS.flatMap((list) => (Array.isArray(config[list]) ? config[list] : []).map((item, index) => [
19
+ `${list}[${index}].image_src`,
20
+ item?.image_src,
21
+ ])),
22
+ ];
23
+ for (const [field, source] of sources) {
24
+ if (typeof source !== 'string' || source === '') {
25
+ continue;
26
+ }
27
+ const filePath = path.posix.normalize(source.replaceAll('\\', '/'));
28
+ const stats = statSync(path.join(appPath, filePath), {
29
+ throwIfNoEntry: false,
30
+ });
31
+ if (!stats?.isFile()) {
32
+ warn(`swell.json ${field} names ${source}, which the app does not have: the listing gets no image from it.`);
33
+ }
34
+ else if (!filePath.startsWith('assets/')) {
35
+ warn(`swell.json ${field} names ${source}, which is not in assets/: the listing gets no image from it. Move the file to assets/ and write assets/${path.posix.basename(filePath)}.`);
36
+ }
37
+ else if (filePath !== source) {
38
+ warn(`swell.json ${field} names ${source}, which is not the file's path from the app root: the listing gets no image from it. Write ${filePath}.`);
39
+ }
40
+ }
41
+ }
@@ -1,5 +1,13 @@
1
1
  import { type GlobbyFilterFunction } from 'globby';
2
2
  import { ConfigType } from './index.js';
3
+ export declare function isCredentialFile(relativePath: string): boolean;
4
+ /**
5
+ * The .env files of the frontend that the push would have pushed, which its
6
+ * build reads: their change deploys the frontend again, though they stay local
7
+ * @param {string} appPath the app directory
8
+ * @returns {Promise<string[]>} paths relative to the app, sorted
9
+ */
10
+ export declare function globFrontendEnvFiles(appPath: string): Promise<string[]>;
3
11
  export declare function toPosixPath(inputPath: string): string;
4
12
  export declare function globAllFilesByPath(appPath: string, dirPath?: string): Promise<string[]>;
5
13
  export declare function getGlobIgnorePathsChecker(appPath: string): Promise<GlobbyFilterFunction>;
@@ -2,6 +2,39 @@ import { globby, globbySync, isGitIgnored, } from 'globby';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { AllConfigPaths, ConfigType, StandardConfigPaths, StorefrontConfigPaths, ThemeConfigPaths, filePathExists, } from './index.js';
5
+ // .env and .dev.vars files can hold real credentials: never pushed, whatever
6
+ // the user's .gitignore says
7
+ const CREDENTIAL_FILE_GLOBS = [
8
+ '**/.dev.vars',
9
+ '**/.dev.vars.*',
10
+ '**/.env',
11
+ '**/.env.*',
12
+ ];
13
+ export function isCredentialFile(relativePath) {
14
+ const name = path.posix.basename(toPosixPath(relativePath));
15
+ return (name === '.env' ||
16
+ name.startsWith('.env.') ||
17
+ name === '.dev.vars' ||
18
+ name.startsWith('.dev.vars.'));
19
+ }
20
+ /**
21
+ * The .env files of the frontend that the push would have pushed, which its
22
+ * build reads: their change deploys the frontend again, though they stay local
23
+ * @param {string} appPath the app directory
24
+ * @returns {Promise<string[]>} paths relative to the app, sorted
25
+ */
26
+ export async function globFrontendEnvFiles(appPath) {
27
+ const files = await globby([
28
+ `${AllConfigPaths.FRONTEND}/**/.env`,
29
+ `${AllConfigPaths.FRONTEND}/**/.env.*`,
30
+ ], {
31
+ cwd: appPath,
32
+ dot: true,
33
+ gitignore: true,
34
+ ignore: ['**/node_modules/**', '**/.wrangler/**'],
35
+ });
36
+ return files.sort();
37
+ }
5
38
  function globOptions(appPath, options) {
6
39
  return {
7
40
  cwd: appPath,
@@ -9,8 +42,6 @@ function globOptions(appPath, options) {
9
42
  gitignore: true,
10
43
  ...options,
11
44
  expandDirectories: true,
12
- // .dev.vars can hold real credentials — ignored here in case the
13
- // user's .gitignore doesn't cover it.
14
45
  ignore: [
15
46
  '**/.swellrc',
16
47
  '**/.git/**',
@@ -21,8 +52,7 @@ function globOptions(appPath, options) {
21
52
  '**/bun.lockb',
22
53
  '**/bun.lock',
23
54
  '**/.wrangler/**',
24
- '**/.dev.vars',
25
- '**/.dev.vars.*',
55
+ ...CREDENTIAL_FILE_GLOBS,
26
56
  ...(options?.ignore || []),
27
57
  ],
28
58
  };
@@ -60,7 +60,7 @@ export async function bundleFunction(filePath) {
60
60
  }
61
61
  export async function bundleWorkflow(filePath, analysis) {
62
62
  try {
63
- const entrySource = getSwellWorkflowEntrypoint(filePath);
63
+ const entrySource = await getSwellWorkflowEntrypoint(filePath);
64
64
  const buildResult = await esbuild.build({
65
65
  bundle: true,
66
66
  stdin: {
@@ -130,13 +130,22 @@ async function getSwellFunctionWrapper() {
130
130
  const __dirname = path.dirname(__filename);
131
131
  return fs.readFile(path.join(__dirname, './swell-function-wrapper.js'), 'utf8');
132
132
  }
133
- function getSwellWorkflowEntrypoint(filePath) {
133
+ // The entry defines these functions itself, so the bundle names no file of
134
+ // the CLI and the workflow module exports only its entrypoint
135
+ async function getSwellWorkflowRequest() {
136
+ const source = await fs.readFile(path.join(path.dirname(fileURLToPath(import.meta.url)), './swell-workflow-request.js'), 'utf8');
137
+ return source.replaceAll(/^export /gm, '');
138
+ }
139
+ async function getSwellWorkflowEntrypoint(filePath) {
134
140
  const workflowImport = `./${path.basename(filePath)}`;
141
+ const workflowRequest = await getSwellWorkflowRequest();
135
142
  return `
136
143
  import { WorkflowEntrypoint } from 'cloudflare:workers';
137
144
  import { NonRetryableError } from 'cloudflare:workflows';
138
145
  import UserWorkflow from ${JSON.stringify(workflowImport)};
139
146
 
147
+ ${workflowRequest}
148
+
140
149
  const RUNTIME_PROXY_PATH = '/:workflows/runtime/request';
141
150
  const RUNTIME_TOKEN_PATTERN = /\\bwf_rt_[0-9A-Za-z]+_[0-9A-Za-z]{12,}\\b/g;
142
151
 
@@ -210,38 +219,6 @@ function getInvocation(event) {
210
219
  return payload;
211
220
  }
212
221
 
213
- function buildWorkflowRequest(invocation, proxy) {
214
- const runtime = invocation.runtime;
215
-
216
- return {
217
- id: runtime.request_id,
218
- appId: runtime.app_slug || runtime.app_id,
219
- environmentId: runtimeEnvironmentId(runtime),
220
- store: {
221
- id: runtime.store_id,
222
- },
223
- app: {
224
- admin_url: runtime.app_admin_url,
225
- },
226
- data: invocation.data,
227
- workflow: {
228
- workflow_id: runtime.workflow_id,
229
- workflow_name: runtime.workflow_name,
230
- workflow_instance_id: runtime.workflow_instance_id,
231
- trigger: runtime.trigger,
232
- request_id: runtime.request_id,
233
- },
234
- isLocalDev: false,
235
- swell: {
236
- get: (path, data) => proxy.api('get', path, data),
237
- post: (path, data) => proxy.api('post', path, data),
238
- put: (path, data) => proxy.api('put', path, data),
239
- delete: (path, data) => proxy.api('delete', path, data),
240
- settings: () => proxy.settings(),
241
- },
242
- };
243
- }
244
-
245
222
  function buildWorkflowStep(cloudflareStep, proxy) {
246
223
  return Object.freeze({
247
224
  do(name, optionsOrCallback, maybeCallback) {
@@ -289,13 +266,26 @@ class SwellWorkflowRuntimeProxy {
289
266
  this.apiHost = getRuntimeApiHost(runtime);
290
267
  }
291
268
 
292
- api(method, path, data) {
293
- return this.request({
269
+ async api(method, path, data) {
270
+ const result = await this.request({
294
271
  type: 'api',
295
272
  method,
296
273
  path,
297
274
  data,
298
275
  });
276
+
277
+ // A write the API refuses answers with its errors. It fails as it does in
278
+ // functions, and is not retried, since the same write would fail again
279
+ if (method !== 'get' && result && result.errors) {
280
+ const error = new NonRetryableError(
281
+ method.toUpperCase() + ' ' + path + '\\n' + JSON.stringify(result.errors, null, 2),
282
+ );
283
+ error.status = 400;
284
+ error.body = result.errors;
285
+ throw error;
286
+ }
287
+
288
+ return result;
299
289
  }
300
290
 
301
291
  settings() {
@@ -361,11 +351,6 @@ function getRuntimeApiHost(runtime) {
361
351
  return apiHost.replace(/\\/$/, '');
362
352
  }
363
353
 
364
- // 'test' in the test environment, null in live
365
- function runtimeEnvironmentId(runtime) {
366
- return runtime.environment_id || null;
367
- }
368
-
369
354
  function runtimeIdentity(runtime) {
370
355
  return {
371
356
  app_id: runtime.app_id,
@@ -1,4 +1,4 @@
1
- export type FunctionType = 'cron' | 'model' | 'route' | 'workflow';
1
+ export type FunctionType = 'action' | 'cron' | 'model' | 'route' | 'workflow';
2
2
  export type FunctionLanguage = 'js' | 'ts';
3
3
  export interface CreateFunctionArgs {
4
4
  functionName: string;
@@ -11,7 +11,7 @@ export function getFunctionTemplate({ description, events, extension, functionNa
11
11
  const eventsList = events
12
12
  .split(/\s*,\s*/g)
13
13
  .filter(Boolean)
14
- .map((eventName) => `'${eventName}'`)
14
+ .map((eventName) => JSON.stringify(eventName))
15
15
  .join(', ');
16
16
  typeAppend = `,
17
17
  model: {
@@ -28,15 +28,19 @@ export function getFunctionTemplate({ description, events, extension, functionNa
28
28
  if (trigger === 'cron') {
29
29
  typeAppend = `,
30
30
  cron: {
31
- schedule: '${schedule}',
31
+ schedule: ${JSON.stringify(schedule)},
32
32
  },`;
33
+ }
34
+ if (trigger === 'action') {
35
+ typeAppend = `,
36
+ action: true,`;
33
37
  }
34
38
  let extensionAppend = '';
35
39
  if (extension) {
36
- extensionAppend = `extension: '${extension}',\n `;
40
+ extensionAppend = `extension: ${JSON.stringify(extension)},\n `;
37
41
  }
38
42
  return `export const config${language === 'ts' ? ': SwellConfig' : ''} = {
39
- ${extensionAppend}description: '${description}'${typeAppend}
43
+ ${extensionAppend}description: ${JSON.stringify(description)}${typeAppend}
40
44
  };
41
45
 
42
46
  export default async function (req${language === 'ts' ? ': SwellRequest' : ''}) {
@@ -50,7 +54,7 @@ function getWorkflowTemplate({ description, functionName, language, }) {
50
54
  const stepParam = language === 'ts' ? 'step: SwellWorkflowStep' : 'step';
51
55
  return `export const config = {
52
56
  kind: 'workflow',
53
- description: '${description}'
57
+ description: ${JSON.stringify(description)}
54
58
  };
55
59
 
56
60
  export default class ${className} {
@@ -15,8 +15,9 @@ export const toAppId = (value) => String(value || '')
15
15
  .replaceAll(/([a-z])([A-Z])/g, '$1_$2')
16
16
  .toLowerCase();
17
17
  // Example: 'My Thing' => 'my-thing'
18
+ // And: 'sync_order' => 'sync-order', the name a function deploys under
18
19
  // And: 'myThing' => 'myThing'
19
- export const toFunctionFileName = (value) => value.split('.')[0].replace(' ', '-');
20
+ export const toFunctionFileName = (value) => value.split('.')[0].replaceAll(/[\s_]+/g, '-');
20
21
  // Example: 'My Thing' => 'my-things'
21
22
  export const toCollectionName = (value) => pluralize(value.replaceAll(/[^A-Za-z]/g, '-').replace(' ', '')).toLowerCase();
22
23
  // Example: 'my-things' => 'My Things'
@@ -13,11 +13,15 @@ interface ModelEvent {
13
13
  id: ModelEventID;
14
14
  }
15
15
  type ModelEventID = 'created' | 'deleted' | 'updated';
16
- type ModelFieldResponse = Record<string, ModelFieldTypeObject>;
17
- type FieldType = 'collection' | 'currency' | 'image' | 'int' | 'link' | 'string';
18
- interface ModelFieldTypeObject {
16
+ type ModelFieldResponse = Record<string, ModelField>;
17
+ export declare const FIELD_TYPES: readonly ["string", "int", "float", "bool", "date", "currency", "objectid", "array", "object", "collection", "link", "file", "filedata"];
18
+ type FieldType = (typeof FIELD_TYPES)[number];
19
+ interface ModelField {
20
+ model?: string;
19
21
  type: FieldType;
22
+ value_type?: string;
20
23
  }
24
+ export declare function fieldPairProblem(pair: string): string | undefined;
21
25
  export declare function parseFields(fields: string[]): ModelFieldResponse;
22
26
  export declare function parseEvents(events: string[]): ModelEventObject;
23
27
  export {};
@@ -1,14 +1,72 @@
1
- export function parseFields(fields) {
2
- if (fields.length === 0) {
3
- return {};
1
+ // The field types of the model schema, https://json.swell.store/model.json
2
+ export const FIELD_TYPES = [
3
+ 'string',
4
+ 'int',
5
+ 'float',
6
+ 'bool',
7
+ 'date',
8
+ 'currency',
9
+ 'objectid',
10
+ 'array',
11
+ 'object',
12
+ 'collection',
13
+ 'link',
14
+ 'file',
15
+ 'filedata',
16
+ ];
17
+ // Items of an array given as a flag; object and link items need more than a type
18
+ const ARRAY_VALUE_TYPES = [
19
+ 'string',
20
+ 'int',
21
+ 'float',
22
+ 'bool',
23
+ 'date',
24
+ 'currency',
25
+ 'objectid',
26
+ 'file',
27
+ 'filedata',
28
+ ];
29
+ function splitFieldPair(pair) {
30
+ return pair.split(':').map((part) => part.trim());
31
+ }
32
+ // Why a field given as id:type, id:link:<model> or id:array:<type> cannot be
33
+ // written, if it cannot
34
+ export function fieldPairProblem(pair) {
35
+ const [id, type, target] = splitFieldPair(pair);
36
+ if (!id || !type) {
37
+ return `${pair}: expected id:type`;
38
+ }
39
+ if (type === 'image') {
40
+ return `${pair}: Invalid field type 'image'; use file`;
41
+ }
42
+ if (type === 'collection') {
43
+ return `${pair}: A collection needs its fields; add it with them to the model file`;
44
+ }
45
+ if (!FIELD_TYPES.includes(type)) {
46
+ return `${pair}: unknown type '${type}'`;
47
+ }
48
+ if (type === 'link' && !target) {
49
+ return `${pair}: a link needs the model it links to, e.g. ${id}:link:accounts`;
4
50
  }
51
+ if (type === 'array' && target && !ARRAY_VALUE_TYPES.includes(target)) {
52
+ return `${pair}: an array given here holds ${ARRAY_VALUE_TYPES.join(', ')}`;
53
+ }
54
+ if (target && type !== 'link' && type !== 'array') {
55
+ return `${pair}: only link and array take a third part`;
56
+ }
57
+ }
58
+ export function parseFields(fields) {
5
59
  const fieldResponse = {};
6
- for (const fieldPair of fields) {
7
- if (fieldPair.includes(':')) {
8
- const [id, type] = fieldPair.split(':');
9
- fieldResponse[id] = {
10
- type,
11
- };
60
+ for (const pair of fields) {
61
+ const [id, type, target] = splitFieldPair(pair);
62
+ if (type === 'link') {
63
+ fieldResponse[id] = { type, model: target };
64
+ }
65
+ else if (type === 'array') {
66
+ fieldResponse[id] = { type, value_type: target || 'string' };
67
+ }
68
+ else {
69
+ fieldResponse[id] = { type };
12
70
  }
13
71
  }
14
72
  return fieldResponse;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The types an app's functions type-check against: the app's own
3
+ * @swell/app-types when it has the package, otherwise the CLI's.
4
+ *
5
+ * @param directory a directory of the app
6
+ * @returns the declarations, after a line naming the package version
7
+ */
8
+ export declare function getFunctionDeclarations(directory: string): Promise<string>;
@@ -0,0 +1,27 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import * as path from 'node:path';
4
+ /**
5
+ * The types an app's functions type-check against: the app's own
6
+ * @swell/app-types when it has the package, otherwise the CLI's.
7
+ *
8
+ * @param directory a directory of the app
9
+ * @returns the declarations, after a line naming the package version
10
+ */
11
+ export async function getFunctionDeclarations(directory) {
12
+ const file = resolveAppTypes(path.join(directory, 'package.json')) ??
13
+ resolveAppTypes(import.meta.url);
14
+ if (!file) {
15
+ throw new Error('@swell/app-types is not installed');
16
+ }
17
+ const { version } = JSON.parse(await fs.readFile(path.join(path.dirname(file), 'package.json'), 'utf8'));
18
+ return `// @swell/app-types ${version}\n${await fs.readFile(file, 'utf8')}`;
19
+ }
20
+ function resolveAppTypes(from) {
21
+ try {
22
+ return createRequire(from).resolve('@swell/app-types/index.d.ts');
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }