@swell/cli 2.3.2 → 2.3.4

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.
@@ -297,6 +297,8 @@ ENVIRONMENT = "development"
297
297
  // Get all functions in this app
298
298
  const functions = await this.getAppFunctions();
299
299
  if (functions.length === 0) {
300
+ // Create routing server to proxy requests to frontend even without functions
301
+ await this.createFunctionRouter(serverPort);
300
302
  spinner.stop();
301
303
  return;
302
304
  }
@@ -308,7 +310,7 @@ ENVIRONMENT = "development"
308
310
  this.watchForChanges({
309
311
  onChange: this.onChangeFunctionWatcher.bind(this),
310
312
  });
311
- // Create a routing server that proxies requests to function servers
313
+ // Create routing server after function servers are ready
312
314
  await this.createFunctionRouter(serverPort);
313
315
  spinner.succeed(`App function server running on port ${serverPort}\n`);
314
316
  this.log(`${style.appConfigName(`Functions:`)}`);
@@ -16,7 +16,7 @@ export default class Models extends SwellCommand {
16
16
  private showStandardModels;
17
17
  private showAppModels;
18
18
  private showModels;
19
- private showCollectionPath;
19
+ private getModelPath;
20
20
  private getCurrentAppSlugId;
21
21
  private getAppSlugId;
22
22
  private showModelDetail;
@@ -31,6 +31,7 @@ With a collection path, it retrieves the model for that specific collection in J
31
31
  static examples = [
32
32
  'swell inspect models',
33
33
  'swell inspect models /products',
34
+ 'swell inspect models /content/blogs',
34
35
  'swell inspect models /apps/myapp/orders',
35
36
  'swell inspect models /orders --live',
36
37
  ];
@@ -69,7 +70,6 @@ With a collection path, it retrieves the model for that specific collection in J
69
70
  adminPath: '/data/:models',
70
71
  }, {
71
72
  query: {
72
- content_id: { $exists: false },
73
73
  deprecated: { $ne: true },
74
74
  development: { $ne: true },
75
75
  abstract: { $ne: true },
@@ -102,6 +102,7 @@ With a collection path, it retrieves the model for that specific collection in J
102
102
  this.log();
103
103
  this.log('Examples:');
104
104
  this.log(' swell inspect models /products');
105
+ this.log(' swell inspect models /content/blogs');
105
106
  this.log(' swell inspect models /apps/myapp/orders');
106
107
  }
107
108
  showStandardModels(models) {
@@ -123,20 +124,34 @@ With a collection path, it retrieves the model for that specific collection in J
123
124
  showModels(models, label, options) {
124
125
  this.log(label);
125
126
  this.log();
126
- const sortedModels = [...models].sort((a, b) => a.name.localeCompare(b.name));
127
+ const sortedModels = [...models].sort((a, b) => {
128
+ const pathA = this.getModelPath(a, options?.pathPrefix);
129
+ const pathB = this.getModelPath(b, options?.pathPrefix);
130
+ return pathA.localeCompare(pathB);
131
+ });
127
132
  for (const model of sortedModels) {
128
- this.showCollectionPath(model.name, 2, options);
133
+ const modelPath = this.getModelPath(model, options?.pathPrefix);
134
+ this.log(` ${modelPath}`);
129
135
  const sortedSubModels = Object.values(model.fields)
130
136
  .filter((field) => field.type === 'collection')
131
137
  .map((field) => field.name)
132
138
  .sort((a, b) => a.localeCompare(b));
133
139
  for (const sub of sortedSubModels) {
134
- this.showCollectionPath(sub, 4, options);
140
+ const subPath = options?.pathPrefix
141
+ ? `${options.pathPrefix}/${sub}`
142
+ : `/${sub}`;
143
+ this.log(` ${subPath}`);
135
144
  }
136
145
  }
137
146
  }
138
- showCollectionPath(collection, indent = 0, options) {
139
- this.log(`${' '.repeat(indent)}${options?.pathPrefix || ''}/${collection}`);
147
+ getModelPath(model, pathPrefix) {
148
+ if (pathPrefix) {
149
+ return `${pathPrefix}/${model.name}`;
150
+ }
151
+ if (model.namespace) {
152
+ return `/${model.namespace}/${model.name}`;
153
+ }
154
+ return `/${model.name}`;
140
155
  }
141
156
  async getCurrentAppSlugId() {
142
157
  const hasAppContext = fs.existsSync('swell.json');
@@ -155,15 +170,32 @@ With a collection path, it retrieves the model for that specific collection in J
155
170
  return app.public_id || app.private_id.replace(/^_/, '');
156
171
  }
157
172
  async showModelDetail(path) {
158
- const [modelPath, subModelKey] = await this.resolveModelPath(path);
159
- const model = await this.api.get({
160
- adminPath: `/data/:models${modelPath}`,
161
- }, { query: { $app: true } });
173
+ const [modelPath, subModelKey, namespace] = await this.resolveModelPath(path);
174
+ let model = null;
175
+ if (namespace) {
176
+ // For namespaced models, fetch by name and namespace filter
177
+ // since the API may return a different model with the same name
178
+ const modelName = modelPath.slice(1); // Remove leading /
179
+ const { results } = await this.api.get({ adminPath: '/data/:models' }, {
180
+ query: {
181
+ name: modelName,
182
+ namespace,
183
+ $app: true,
184
+ },
185
+ });
186
+ model = results?.[0] ?? null;
187
+ }
188
+ else {
189
+ model = await this.api.get({
190
+ adminPath: `/data/:models${modelPath}`,
191
+ }, { query: { $app: true } });
192
+ }
162
193
  if (!model) {
163
194
  this.throwModelNotFound(path);
164
195
  }
165
196
  if (!subModelKey) {
166
- return this.showModel(model);
197
+ this.showModel(model);
198
+ return;
167
199
  }
168
200
  const subModel = model.fields[subModelKey];
169
201
  if (!subModel) {
@@ -176,6 +208,13 @@ With a collection path, it retrieves the model for that specific collection in J
176
208
  return this.resolveAppModelPath(path);
177
209
  }
178
210
  const [modelPath, subModelKey] = path.split(':');
211
+ // Check if this is a namespaced path (e.g., /content/blogs)
212
+ const pathParts = modelPath.slice(1).split('/');
213
+ if (pathParts.length === 2) {
214
+ const [namespace, modelName] = pathParts;
215
+ // Return model name and namespace for filtering
216
+ return [`/${modelName}`, subModelKey, namespace];
217
+ }
179
218
  return [modelPath, subModelKey];
180
219
  }
181
220
  async resolveAppModelPath(path) {
@@ -18,7 +18,6 @@ export default class Logs extends SwellCommand {
18
18
  static summary: string;
19
19
  private output;
20
20
  run(): Promise<void>;
21
- __run(): Promise<void>;
22
21
  private getLogs;
23
22
  private getLogsAndWriteToStream;
24
23
  }
@@ -1,5 +1,4 @@
1
1
  import { Flags } from '@oclif/core';
2
- import ora from 'ora';
3
2
  import { LineOutput, LoggedItem, TableOutput } from '../lib/logs/index.js';
4
3
  import { SwellCommand } from '../swell-command.js';
5
4
  // the columns available to display in the table
@@ -19,6 +18,23 @@ const OUTPUT_COLUMNS = [
19
18
  const OUTPUT_COLUMNS_DEFAULTS = ['date', 'request', 'data', 'status', 'time'];
20
19
  // the interval to poll the API when following logs
21
20
  const FOLLOW_POLLING_INTERVAL = 1000 * 2; // 2 seconds
21
+ /**
22
+ * Converts $in value to array
23
+ * @param value value to convert
24
+ * @param op operator
25
+ * @param convertToNumber true if string value should be converted to number
26
+ * @returns converted value
27
+ */
28
+ function convertOperatorValue(value, op, convertToNumber = false) {
29
+ if (Array.isArray(value)) {
30
+ return value;
31
+ }
32
+ const converted = convertToNumber ? Number.parseInt(value, 10) : value;
33
+ if (op !== '$in') {
34
+ return converted;
35
+ }
36
+ return [converted];
37
+ }
22
38
  /**
23
39
  * Builds the request options to send to the API based on the flags passed
24
40
  * to the command.
@@ -36,7 +52,7 @@ function buildLogRequestBody(flags) {
36
52
  // some filters are simple and can be mapped directly to the API
37
53
  const logFilters = [
38
54
  { filter: 'app_id', flag: 'app', operator: '$in' },
39
- { filter: 'message.status', flag: 'status', operator: '$in' },
55
+ { filter: 'message.status', flag: 'status', operator: '$in', number: true },
40
56
  { filter: 'message.type', flag: 'type', operator: '$in' },
41
57
  ];
42
58
  // map the flags to the API query
@@ -44,7 +60,9 @@ function buildLogRequestBody(flags) {
44
60
  if (flags[filter.flag]) {
45
61
  andConditions.push({
46
62
  [filter.filter]: filter.operator
47
- ? { [filter.operator]: flags[filter.flag] }
63
+ ? {
64
+ [filter.operator]: convertOperatorValue(flags[filter.flag], filter.operator, filter.number),
65
+ }
48
66
  : flags[filter.flag],
49
67
  });
50
68
  }
@@ -145,7 +163,7 @@ export default class Logs extends SwellCommand {
145
163
  char: 'p',
146
164
  default: false,
147
165
  description: 'note that this flag will take more space in the terminal and require more time to load',
148
- summary: 'pretty print json data',
166
+ summary: 'pretty print json data in table output',
149
167
  }),
150
168
  search: Flags.string({
151
169
  char: 's',
@@ -163,10 +181,6 @@ export default class Logs extends SwellCommand {
163
181
  static summary = 'Output or stream store logs to the terminal.';
164
182
  output;
165
183
  async run() {
166
- const spinner = ora();
167
- spinner.fail('This command is temporarily disabled. Check back soon.');
168
- }
169
- async __run() {
170
184
  const { flags } = await this.parse(Logs);
171
185
  // indentify the columns to display
172
186
  const columns = flags.columns.split(',');
@@ -175,24 +189,30 @@ export default class Logs extends SwellCommand {
175
189
  flags.output === 'table'
176
190
  ? new TableOutput(columns, flags.pretty)
177
191
  : new LineOutput(columns, flags.pretty);
178
- // first run for getting the logs
179
- // we keep track of the last date we received so we can get logs after that
180
- flags.startPolling = await this.getLogsAndWriteToStream(flags);
181
192
  // if following, poll the API every FOLLOW_POLLING_INTERVAL seconds and
182
193
  // write new logs to the stream
183
194
  if (flags.follow) {
195
+ // first get the latest log to use its date
196
+ // we keep track of the last date we received so we can get logs after that
197
+ const previousNumber = flags.number;
198
+ flags.number = 1; // get the latest log
199
+ flags.startPolling = await this.getLogsAndWriteToStream(flags, false);
200
+ flags.number = previousNumber;
184
201
  setInterval(async () => {
185
202
  // when polling, we want to get logs after the last date we received
186
203
  flags.startPolling = await this.getLogsAndWriteToStream(flags);
187
204
  }, FOLLOW_POLLING_INTERVAL);
188
205
  }
206
+ else {
207
+ await this.getLogsAndWriteToStream(flags);
208
+ }
189
209
  }
190
210
  async getLogs(flags) {
191
211
  const body = buildLogRequestBody(flags);
192
212
  const response = await this.api.post({ adminPath: `/data/$get/:logs` }, { body });
193
213
  return response?.results?.reverse() || [];
194
214
  }
195
- async getLogsAndWriteToStream(flags) {
215
+ async getLogsAndWriteToStream(flags, show = true) {
196
216
  const logs = await this.getLogs(flags);
197
217
  let lastDate = '';
198
218
  if (!this.output) {
@@ -200,12 +220,13 @@ export default class Logs extends SwellCommand {
200
220
  }
201
221
  if (logs.length > 0) {
202
222
  lastDate = logs.at(-1).date;
203
- for (const log of logs) {
204
- this.output.write(this.output.prepareData(new LoggedItem(log)));
223
+ if (show) {
224
+ for (const log of logs) {
225
+ this.output.write(this.output.prepareData(new LoggedItem(log)));
226
+ }
205
227
  }
206
228
  }
207
- // if the user is following logs, we want to return the last date we
208
- // received
209
- return lastDate || flags.startPolling;
229
+ // if the user is following logs, we want to return the last date we received
230
+ return lastDate || flags.startPolling || new Date().toISOString();
210
231
  }
211
232
  }
package/dist/lib/api.js CHANGED
@@ -177,6 +177,7 @@ export default class Api {
177
177
  await this.setEnv(envId);
178
178
  }
179
179
  this.storeId = storeId;
180
+ this.envId = envId;
180
181
  }
181
182
  isAdmin() {
182
183
  return !this.secretKey;
@@ -89,6 +89,12 @@ export declare class AppConfigFunction extends AppConfig {
89
89
  isRootFunction(): boolean;
90
90
  preparePostData(postData: any): Promise<any>;
91
91
  }
92
+ export declare class AppConfigComponent extends AppConfig {
93
+ hasValues: boolean;
94
+ type: ConfigType;
95
+ isRootComponent(): boolean;
96
+ preparePostData(postData: any): Promise<any>;
97
+ }
92
98
  export declare class AppConfigAsset extends AppConfigDefault {
93
99
  hasValues: boolean;
94
100
  type: ConfigType;
@@ -3,7 +3,7 @@ import isEmpty from 'lodash/isEmpty.js';
3
3
  import { detectFilenameMime } from 'mime-detect';
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
- import { bundleFunction } from '../bundle.js';
6
+ import { bundleFunction, bundleComponent } from '../bundle.js';
7
7
  import { AllConfigPaths, ConfigType, filePathExists, hashFile, } from './index.js';
8
8
  export class IgnoringFileError extends Error {
9
9
  constructor(message) {
@@ -117,6 +117,9 @@ export class AppConfig {
117
117
  case ConfigType.THEME: {
118
118
  return new AppConfigTheme(attrs);
119
119
  }
120
+ case ConfigType.COMPONENT: {
121
+ return new AppConfigComponent(attrs);
122
+ }
120
123
  default: {
121
124
  // the default type is file
122
125
  const defaultConfig = new AppConfigDefault(attrs);
@@ -275,6 +278,43 @@ export class AppConfigFunction extends AppConfig {
275
278
  return postData;
276
279
  }
277
280
  }
281
+ export class AppConfigComponent extends AppConfig {
282
+ hasValues = true;
283
+ type = ConfigType.COMPONENT;
284
+ isRootComponent() {
285
+ return (this.isRootConfig('components') &&
286
+ (this.filePath.endsWith('.jsx') || this.filePath.endsWith('.tsx')));
287
+ }
288
+ async preparePostData(postData) {
289
+ if (!this.isRootComponent()) {
290
+ return postData;
291
+ }
292
+ try {
293
+ // get file contents and if it's empty ignore
294
+ const fileData = this.prepareFileData();
295
+ if (!fileData) {
296
+ return;
297
+ }
298
+ const { code, config } = await bundleComponent(this.filePath);
299
+ if (!config) {
300
+ throw new IgnoringFileError('Component must export a `config` object.');
301
+ }
302
+ // Save the original file and the bundled version
303
+ postData.file = {
304
+ data: fileData,
305
+ };
306
+ postData.build_file = {
307
+ content_type: 'application/javascript',
308
+ data: code,
309
+ };
310
+ postData.values = config;
311
+ }
312
+ catch (error) {
313
+ throw new FunctionProcessingError(`Unable to compile component ${this.name}`, error);
314
+ }
315
+ return postData;
316
+ }
317
+ }
278
318
  // Assets do not get installed but saved as plain files
279
319
  export class AppConfigAsset extends AppConfigDefault {
280
320
  hasValues = false;
@@ -93,6 +93,7 @@ export declare enum ConfigType {
93
93
  CONTENT = "content",
94
94
  FILE = "file",
95
95
  FRONTEND = "frontend",
96
+ COMPONENT = "component",
96
97
  FUNCTION = "function",
97
98
  MODEL = "model",
98
99
  NOTIFICATION = "notification",
@@ -26,6 +26,7 @@ export var ConfigType;
26
26
  ConfigType["CONTENT"] = "content";
27
27
  ConfigType["FILE"] = "file";
28
28
  ConfigType["FRONTEND"] = "frontend";
29
+ ConfigType["COMPONENT"] = "component";
29
30
  ConfigType["FUNCTION"] = "function";
30
31
  ConfigType["MODEL"] = "model";
31
32
  ConfigType["NOTIFICATION"] = "notification";
@@ -44,6 +45,7 @@ const ConfigTypeBatchOrder = [
44
45
  ConfigType.WEBHOOK,
45
46
  ConfigType.THEME,
46
47
  ConfigType.FRONTEND,
48
+ ConfigType.COMPONENT,
47
49
  ConfigType.FILE,
48
50
  ];
49
51
  // All available configs
@@ -51,6 +53,7 @@ const AllConfigTypes = [
51
53
  'ASSET',
52
54
  'CONTENT',
53
55
  'FRONTEND',
56
+ 'COMPONENT',
54
57
  'FUNCTION',
55
58
  'MODEL',
56
59
  'NOTIFICATION',
@@ -1 +1,2 @@
1
1
  export declare function bundleFunction(filePath: string): Promise<any>;
2
+ export declare function bundleComponent(filePath: string): Promise<any>;
@@ -36,6 +36,45 @@ export async function bundleFunction(filePath) {
36
36
  throw new Error(`Unable to compile function ${filePath}: ${error.message}`);
37
37
  }
38
38
  }
39
+ export async function bundleComponent(filePath) {
40
+ try {
41
+ const content = fs.readFileSync(filePath, 'utf8');
42
+ const stdinLoader = filePath.endsWith('.tsx') ? 'tsx' : 'jsx';
43
+ const buildResult = await esbuild.build({
44
+ stdin: {
45
+ contents: `
46
+ ${content}
47
+ import { render, h } from "preact";
48
+ export const preact = { render, h };
49
+ `,
50
+ loader: stdinLoader,
51
+ resolveDir: path.dirname(filePath),
52
+ },
53
+ bundle: true,
54
+ minify: true,
55
+ platform: 'browser',
56
+ format: 'iife',
57
+ globalName: 'Component',
58
+ loader: {
59
+ '.ts': 'ts',
60
+ '.jsx': 'jsx',
61
+ '.tsx': 'tsx',
62
+ },
63
+ jsxFactory: 'h',
64
+ jsxFragment: 'Fragment',
65
+ write: false,
66
+ logLevel: 'silent',
67
+ });
68
+ const { outputFiles: [{ text: code }], } = buildResult;
69
+ // eslint-disable-next-line no-new-func
70
+ const evalFn = new Function(`${code}\nreturn { ...Component }`);
71
+ const { config } = evalFn();
72
+ return { code, config };
73
+ }
74
+ catch (error) {
75
+ throw new Error(`Unable to compile component ${filePath}: ${error.message}`);
76
+ }
77
+ }
39
78
  function getSwellFunctionWrapper() {
40
79
  const __filename = fileURLToPath(import.meta.url);
41
80
  const __dirname = path.dirname(__filename);
@@ -79,7 +79,7 @@ export class TableOutput extends Output {
79
79
  width: 6,
80
80
  },
81
81
  };
82
- return columns[column];
82
+ return columns[column] || { width: 6 };
83
83
  }
84
84
  prepareColumnDisplay(column, extraWidth) {
85
85
  const display = this.displayOptions(column);
@@ -792,14 +792,14 @@ export class PushAppCommand extends RemoteAppCommand {
792
792
  let deploymentUrl;
793
793
  let interactiveError = false;
794
794
  let pagesProjectError = false;
795
+ let outputBuffer = '';
795
796
  this.log(`\nDeploying to Cloudflare...\n`);
796
797
  try {
797
798
  await this.execFrontend(`npx wrangler deploy`, (string) => {
798
- // Dependent on wrangler output
799
- // Parse the deployment URL from the wrangler output.
800
- const match = string.match(/^\s*(https:\/\/\S+\.workers\.dev)\s*[\S\s]*?Current Version ID: [\w-]+/m);
801
- if (match && match.length > 0) {
802
- deploymentUrl = match[1];
799
+ // Accumulate output for URL parsing after command completes
800
+ outputBuffer += string;
801
+ // Suppress workers.dev URL from output (users should use swell domain)
802
+ if (string.match(/^\s*https:\/\/\S+\.workers\.dev\s*$/m)) {
803
803
  return false;
804
804
  }
805
805
  // Check for Pages project error
@@ -834,6 +834,12 @@ export class PushAppCommand extends RemoteAppCommand {
834
834
  throw error;
835
835
  }
836
836
  }
837
+ // Parse deployment URL from accumulated output
838
+ // Match the workers.dev URL that appears on its own line after "Deployed ... triggers"
839
+ const urlMatch = outputBuffer.match(/^\s*(https:\/\/\S+\.workers\.dev)\s*$/m);
840
+ if (urlMatch && urlMatch[1]) {
841
+ deploymentUrl = urlMatch[1];
842
+ }
837
843
  if (!deploymentUrl) {
838
844
  this.error('Unable to retrieve deployment URL.');
839
845
  }
@@ -190,7 +190,7 @@
190
190
  "char": "p",
191
191
  "description": "note that this flag will take more space in the terminal and require more time to load",
192
192
  "name": "pretty",
193
- "summary": "pretty print json data",
193
+ "summary": "pretty print json data in table output",
194
194
  "allowNo": false,
195
195
  "type": "boolean"
196
196
  },
@@ -999,7 +999,7 @@
999
999
  "name": "targetPath"
1000
1000
  }
1001
1001
  },
1002
- "description": "Pull all app files, a specific file, or a specific configuration\ntype from an app in your store's test environment to your local machine.\n\nIf APPID is not specified, you will be prompted with a list of apps to choose from.\n\nApp file directories:\nassets/\ncontent/\nfrontend/\nfunctions/\nmodels/\nnotifications/\nsettings/\ntheme/\nwebhooks/\nfrontend/",
1002
+ "description": "Pull all app files, a specific file, or a specific configuration\ntype from an app in your store's test environment to your local machine.\n\nIf APPID is not specified, you will be prompted with a list of apps to choose from.\n\nApp file directories:\nassets/\ncontent/\nfrontend/\ncomponents/\nfunctions/\nmodels/\nnotifications/\nsettings/\ntheme/\nwebhooks/\nfrontend/",
1003
1003
  "examples": [
1004
1004
  "swell app pull",
1005
1005
  "swell app pull example_app",
@@ -1048,7 +1048,7 @@
1048
1048
  "name": "file"
1049
1049
  }
1050
1050
  },
1051
- "description": "Push all app files, a specific file, or a specific configuration\ntype to an app in your store's test environment.\n\nIf the app does not exist, it will be created and its global ID saved to a .swellrc file.\n\n- If no file is specified, all configuration files will be pushed to the store.\n This includes the app icon (assets/icon.png) and swell.json.\n- If a file is specified, only that file will be pushed to the store.\n- If a directory is specified, only files in that directory will be pushed.\n\nApp file directories:\nassets/\ncontent/\nfrontend/\nfunctions/\nmodels/\nnotifications/\nsettings/\ntheme/\nwebhooks/",
1051
+ "description": "Push all app files, a specific file, or a specific configuration\ntype to an app in your store's test environment.\n\nIf the app does not exist, it will be created and its global ID saved to a .swellrc file.\n\n- If no file is specified, all configuration files will be pushed to the store.\n This includes the app icon (assets/icon.png) and swell.json.\n- If a file is specified, only that file will be pushed to the store.\n- If a directory is specified, only files in that directory will be pushed.\n\nApp file directories:\nassets/\ncontent/\nfrontend/\ncomponents/\nfunctions/\nmodels/\nnotifications/\nsettings/\ntheme/\nwebhooks/",
1052
1052
  "examples": [
1053
1053
  "swell app push",
1054
1054
  "swell app push content",
@@ -2326,6 +2326,7 @@
2326
2326
  "examples": [
2327
2327
  "swell inspect models",
2328
2328
  "swell inspect models /products",
2329
+ "swell inspect models /content/blogs",
2329
2330
  "swell inspect models /apps/myapp/orders",
2330
2331
  "swell inspect models /orders --live"
2331
2332
  ],
@@ -2875,5 +2876,5 @@
2875
2876
  ]
2876
2877
  }
2877
2878
  },
2878
- "version": "2.3.2"
2879
+ "version": "2.3.4"
2879
2880
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swell/cli",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
4
4
  "type": "module",
5
5
  "description": "Swell's command line interface/utility",
6
6
  "keywords": [