@swell/cli 2.9.0 → 2.9.3

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.
@@ -0,0 +1,319 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { InspectResourceCommand, inspectResourceBaseFlags, } from '../../inspect-resource-command.js';
3
+ import { classifyIdentifier, } from '../../lib/apps/inspect-scope.js';
4
+ import { buildAppSlugMap } from '../../lib/apps/resolve.js';
5
+ import { default as localConfig } from '../../lib/config.js';
6
+ import { renderKeyMetaTable } from '../../lib/inspect/table.js';
7
+ import { isWorkflowInstanceIdentifier, redactWorkflowPayload, resolveWorkflowOperationRef, resolveWorkflowScope, workflowRunDate, workflowDisplayName, workflowRunMeta, WORKFLOW_STATUSES, } from '../../lib/workflows/operations.js';
8
+ export default class WorkflowRuns extends InspectResourceCommand {
9
+ static summary = 'Workflow runtime instances.';
10
+ static description = `Lists recent workflow runtime instances, or shows one workflow run by instance id.
11
+
12
+ Identifier forms:
13
+ (none) - recent runs across installed apps, or --app scope
14
+ wf_inst_<id> - single run detail
15
+
16
+ Use --workflow=<workflow> to filter list mode by workflow name, app.<app>.<workflow>, or manifest id.
17
+ `;
18
+ static args = {
19
+ identifier: Args.string({
20
+ description: 'wf_inst_* workflow run instance id.',
21
+ required: false,
22
+ }),
23
+ };
24
+ static flags = {
25
+ ...inspectResourceBaseFlags,
26
+ limit: Flags.integer({
27
+ default: 10,
28
+ description: 'Maximum runs to show.',
29
+ }),
30
+ status: Flags.string({
31
+ description: 'Workflow run status.',
32
+ options: WORKFLOW_STATUSES,
33
+ }),
34
+ workflow: Flags.string({
35
+ description: 'Filter by workflow name, app.<app>.<workflow>, or manifest id.',
36
+ }),
37
+ };
38
+ static examples = [
39
+ 'swell inspect workflow-runs',
40
+ 'swell inspect workflow-runs --app=my-app --status active',
41
+ 'swell inspect workflow-runs --workflow run-import --app=my-app',
42
+ 'swell inspect workflow-runs --workflow app.my-app.run-import --status active',
43
+ 'swell inspect workflow-runs wf_inst_abc123 --app=my-app',
44
+ 'swell inspect workflow-runs wf_inst_abc123',
45
+ ];
46
+ resourceLabel = 'Workflow runs';
47
+ resourceLabelSingular = 'workflow run';
48
+ adminPath = '/data/:workflows/runs';
49
+ commandName = 'workflow-runs';
50
+ detailContext;
51
+ async run() {
52
+ const { args, flags } = await this.parse(WorkflowRuns);
53
+ if (args.identifier && !isWorkflowInstanceIdentifier(args.identifier)) {
54
+ this.error(`Invalid workflow run identifier '${args.identifier}'. Expected wf_inst_* instance id. Use --workflow=<workflow> to filter run lists.`, { exit: 1 });
55
+ }
56
+ if (args.identifier && flags.workflow) {
57
+ this.error('--workflow is only valid in list mode.', { exit: 1 });
58
+ }
59
+ if (!flags.live) {
60
+ await this.api.setEnv('test');
61
+ }
62
+ if (!args.identifier) {
63
+ if (flags.json) {
64
+ this.error('--json is only valid when inspecting a single wf_inst_* run.', { exit: 1 });
65
+ }
66
+ await this.showRecentRunList(flags);
67
+ return;
68
+ }
69
+ if (isWorkflowInstanceIdentifier(args.identifier)) {
70
+ await this.showRunDetail(args.identifier, flags);
71
+ }
72
+ }
73
+ hints() {
74
+ if (!this.detailContext) {
75
+ return [];
76
+ }
77
+ const { appId, appPart, live, run, workflowName } = this.detailContext;
78
+ const envFlag = live ? ' --live' : '';
79
+ const lines = [
80
+ `swell logs --type workflow --app ${appPart} -s ${workflowName}${envFlag}`,
81
+ ];
82
+ if (run?.status === 'active' && run.workflow_instance_id) {
83
+ lines.unshift(buildTerminateApiHint({
84
+ appId: appId || run.app_id || appPart,
85
+ instanceId: run.workflow_instance_id,
86
+ live,
87
+ workflowName,
88
+ }));
89
+ }
90
+ return lines;
91
+ }
92
+ async showRecentRunList(flags) {
93
+ const { runs, hasMore, skippedApps } = flags.app
94
+ ? await this.getRecentRunsForScopedApp(flags)
95
+ : await this.getRecentRunsForInstalledApps(flags);
96
+ const limitedRuns = sortRunsByDateDesc(runs).slice(0, flags.limit);
97
+ this.log(`Workflow runs in '${localConfig.getDefaultStore()}' ${flags.live ? '[live]' : '[test]'}`);
98
+ if (limitedRuns.length === 0) {
99
+ this.log();
100
+ this.log(flags.status
101
+ ? ` (no ${flags.status} workflow runs found)`
102
+ : ' (no workflow runs found)');
103
+ this.log();
104
+ return;
105
+ }
106
+ const rows = limitedRuns.map((run) => ({
107
+ key: run.workflow_instance_id || '-',
108
+ meta: workflowRunMetaWithContext(run),
109
+ }));
110
+ this.log();
111
+ for (const line of renderKeyMetaTable(rows)) {
112
+ this.log(line);
113
+ }
114
+ if (skippedApps.length > 0) {
115
+ this.log();
116
+ this.log(`Skipped inaccessible apps: ${skippedApps.join(', ')}`);
117
+ }
118
+ if (hasMore || runs.length > flags.limit) {
119
+ this.log();
120
+ this.log(`Showing first ${flags.limit} runs. Increase --limit to see more.`);
121
+ }
122
+ this.log();
123
+ this.log('Run "swell inspect workflow-runs <wf_inst_id>" to view a run.');
124
+ this.log();
125
+ }
126
+ async showRunDetail(instanceId, flags) {
127
+ const detail = await this.getRunDetail(instanceId, flags);
128
+ const redacted = redactWorkflowPayload(detail);
129
+ const manifest = redacted.manifest || {};
130
+ const appPart = this.detailContext?.appPart || manifest.app_id || '';
131
+ const workflowName = workflowDisplayName(manifest);
132
+ this.detailContext = {
133
+ appId: manifest.app_id,
134
+ appPart,
135
+ live: flags.live,
136
+ run: redacted.run,
137
+ workflowName,
138
+ };
139
+ this.emitDetail(redacted, flags);
140
+ }
141
+ async getRunDetail(instanceId, flags) {
142
+ if (flags.app) {
143
+ const scope = await this.resolveWorkflowRunScope(flags);
144
+ this.detailContext = {
145
+ appPart: scope.appSlug || scope.appId || flags.app,
146
+ workflowName: '',
147
+ };
148
+ return this.getRunDetailForApp(instanceId, scope.appId || '');
149
+ }
150
+ const details = await Promise.all(Object.entries(await buildAppSlugMap(this.api)).map(async ([appId, appSlug]) => {
151
+ try {
152
+ return {
153
+ appPart: appSlug || appId,
154
+ detail: await this.getRunDetailForApp(instanceId, appId),
155
+ };
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }));
161
+ const match = details.find(Boolean);
162
+ if (match) {
163
+ this.detailContext = {
164
+ appPart: match.appPart,
165
+ workflowName: '',
166
+ };
167
+ return match.detail;
168
+ }
169
+ throw new Error(`No workflow run found for '${instanceId}'. Pass --app=<slug> if the app is not listed in this store session.`);
170
+ }
171
+ async resolveWorkflowRunScope(flags) {
172
+ const scope = await resolveWorkflowScope(this.api, { app: flags.app });
173
+ if (!scope.appId) {
174
+ throw new Error('Workflow run detail requires --app=<slug> or --app=.');
175
+ }
176
+ return scope;
177
+ }
178
+ async getRunDetailForApp(instanceId, appId) {
179
+ return this.api.get({ adminPath: `/data/:workflows/runs/${instanceId}` }, { query: { app_id: appId } });
180
+ }
181
+ async getRecentRunsForScopedApp(flags) {
182
+ const [target] = await this.resolveRunListTargets(flags);
183
+ if (!target) {
184
+ throw new Error('Workflow run list requires --app=<slug> or --app=.');
185
+ }
186
+ const response = await this.getRunListForApp(target.appId, flags, target.workflowQuery);
187
+ const runs = (response?.results || []).map((run) => ({
188
+ ...run,
189
+ }));
190
+ return {
191
+ hasMore: Boolean(response?.has_more),
192
+ runs,
193
+ skippedApps: [],
194
+ };
195
+ }
196
+ async getRecentRunsForInstalledApps(flags) {
197
+ const appSlugById = await buildAppSlugMap(this.api);
198
+ const targets = await this.resolveRunListTargets(flags, appSlugById);
199
+ const skippedApps = [];
200
+ let hasMore = false;
201
+ const results = await Promise.all(targets.map(async ({ appId, appSlug, workflowQuery }) => {
202
+ try {
203
+ const response = await this.getRunListForApp(appId, flags, workflowQuery);
204
+ hasMore = hasMore || Boolean(response?.has_more);
205
+ return (response?.results || []).map((run) => ({
206
+ ...run,
207
+ app_slug: appSlug || appSlugById[appId],
208
+ }));
209
+ }
210
+ catch {
211
+ skippedApps.push(appSlug || appSlugById[appId] || appId);
212
+ return [];
213
+ }
214
+ }));
215
+ return {
216
+ hasMore,
217
+ runs: results.flat(),
218
+ skippedApps,
219
+ };
220
+ }
221
+ async resolveRunListTargets(flags, appSlugById) {
222
+ if (flags.app) {
223
+ const scope = await resolveWorkflowScope(this.api, { app: flags.app });
224
+ if (!scope.appId) {
225
+ throw new Error('Workflow run list requires --app=<slug> or --app=.');
226
+ }
227
+ if (!flags.workflow) {
228
+ return [{ appId: scope.appId, appSlug: scope.appSlug }];
229
+ }
230
+ const kind = classifyIdentifier(flags.workflow);
231
+ if (kind.kind === 'name') {
232
+ return [
233
+ {
234
+ appId: scope.appId,
235
+ appSlug: scope.appSlug,
236
+ workflowQuery: { workflow_name: flags.workflow },
237
+ },
238
+ ];
239
+ }
240
+ const ref = await resolveWorkflowOperationRef(this.api, flags.workflow, {
241
+ app: flags.app,
242
+ });
243
+ if (ref.appId !== scope.appId) {
244
+ throw new Error(`--workflow=${flags.workflow} belongs to a different app than --app=${flags.app}.`);
245
+ }
246
+ return [
247
+ {
248
+ appId: ref.appId,
249
+ appSlug: scope.appSlug || ref.appSlug,
250
+ workflowQuery: ref.query,
251
+ },
252
+ ];
253
+ }
254
+ if (flags.workflow) {
255
+ const kind = classifyIdentifier(flags.workflow);
256
+ if (kind.kind === 'name') {
257
+ const slugMap = appSlugById || (await buildAppSlugMap(this.api));
258
+ return Object.entries(slugMap).map(([appId, appSlug]) => ({
259
+ appId,
260
+ appSlug,
261
+ workflowQuery: { workflow_name: flags.workflow },
262
+ }));
263
+ }
264
+ const ref = await resolveWorkflowOperationRef(this.api, flags.workflow, {});
265
+ return [
266
+ {
267
+ appId: ref.appId,
268
+ appSlug: ref.appSlug,
269
+ workflowQuery: ref.query,
270
+ },
271
+ ];
272
+ }
273
+ const slugMap = appSlugById || (await buildAppSlugMap(this.api));
274
+ return Object.entries(slugMap).map(([appId, appSlug]) => ({
275
+ appId,
276
+ appSlug,
277
+ }));
278
+ }
279
+ async getRunListForApp(appId, flags, workflowQuery = {}) {
280
+ const query = {
281
+ app_id: appId,
282
+ ...workflowQuery,
283
+ limit: flags.limit,
284
+ };
285
+ if (flags.status) {
286
+ query.status = flags.status;
287
+ }
288
+ return this.api.get({ adminPath: '/data/:workflows/instances' }, {
289
+ query,
290
+ });
291
+ }
292
+ }
293
+ function sortRunsByDateDesc(runs) {
294
+ return [...runs].sort((a, b) => {
295
+ const aDate = Date.parse(workflowRunDate(a) || '') || 0;
296
+ const bDate = Date.parse(workflowRunDate(b) || '') || 0;
297
+ return bDate - aDate;
298
+ });
299
+ }
300
+ function workflowRunMetaWithContext(run) {
301
+ const context = [
302
+ run.app_slug ? `app.${run.app_slug}` : null,
303
+ run.workflow_name,
304
+ ]
305
+ .filter(Boolean)
306
+ .join('.');
307
+ const meta = workflowRunMeta(run);
308
+ return [context, meta].filter(Boolean).join(' · ');
309
+ }
310
+ function buildTerminateApiHint({ appId, instanceId, live, workflowName, }) {
311
+ const body = JSON.stringify({
312
+ app_id: appId,
313
+ instance_id: instanceId,
314
+ source: 'cli',
315
+ workflow_name: workflowName,
316
+ });
317
+ const envFlag = live ? ' --live' : '';
318
+ return `swell api post '/:workflows/instances/terminate' --body '${body}'${envFlag}`;
319
+ }
@@ -0,0 +1,33 @@
1
+ import { InspectResourceCommand, InspectResourceCommandParsed } from '../../inspect-resource-command.js';
2
+ import { InspectScope } from '../../lib/apps/inspect-scope.js';
3
+ import { WorkflowManifestRecord } from '../../lib/workflows/operations.js';
4
+ export default class Workflows extends InspectResourceCommand {
5
+ static summary: string;
6
+ static description: string;
7
+ static args: {
8
+ identifier: import("@oclif/core/lib/interfaces/parser.js").Arg<string | undefined, Record<string, unknown>>;
9
+ };
10
+ static flags: {
11
+ app: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
+ live: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
13
+ json: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
14
+ yes: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
+ };
16
+ static examples: string[];
17
+ protected resourceLabel: string;
18
+ protected resourceLabelSingular: string;
19
+ protected adminPath: string;
20
+ protected commandName: string;
21
+ private appSlugById;
22
+ private skippedApps;
23
+ private detailRef?;
24
+ run(): Promise<void>;
25
+ protected keyFor(record: WorkflowManifestRecord, appSlugById: Record<string, string>): string;
26
+ protected metaFor(record: WorkflowManifestRecord): string | undefined;
27
+ protected getListResults(scope: InspectScope, _flags: InspectResourceCommandParsed['flags']): Promise<WorkflowManifestRecord[]>;
28
+ protected getAppSlugById(scope: InspectScope): Promise<Record<string, string>>;
29
+ protected afterList(): void;
30
+ protected showDetail(identifier: string, _scope: InspectScope, flags: InspectResourceCommandParsed['flags']): Promise<void>;
31
+ protected hints(): string[];
32
+ private getWorkflowListForApp;
33
+ }
@@ -0,0 +1,99 @@
1
+ import { InspectResourceCommand, inspectResourceBaseArgs, inspectResourceBaseFlags, } from '../../inspect-resource-command.js';
2
+ import { buildAppSlugMap } from '../../lib/apps/resolve.js';
3
+ import { redactWorkflowPayload, resolveWorkflowOperationRef, workflowDisplayName, workflowListMeta, } from '../../lib/workflows/operations.js';
4
+ export default class Workflows extends InspectResourceCommand {
5
+ static summary = 'Durable app workflows and runtime state.';
6
+ static description = `Lists workflow manifests across deployed apps with runtime state. Pass --app=<slug> or --app=. (current swell.json) to scope.
7
+
8
+ Pass an identifier to view a workflow manifest, summary counts, and failed instance sample as JSON.
9
+
10
+ Identifier forms:
11
+ app.<app>.<name> - full paste-back key (list column 1)
12
+ <name> - requires --app= scope
13
+ <24-char id> - any scope
14
+ `;
15
+ static args = { ...inspectResourceBaseArgs };
16
+ static flags = { ...inspectResourceBaseFlags };
17
+ static examples = [
18
+ 'swell inspect workflows',
19
+ 'swell inspect workflows --app=my-app',
20
+ 'swell inspect workflows app.my-app.run-import',
21
+ 'swell inspect workflows run-import --app=my-app',
22
+ 'swell inspect workflows --live',
23
+ ];
24
+ resourceLabel = 'Workflows';
25
+ resourceLabelSingular = 'workflow';
26
+ adminPath = '/data/:workflows';
27
+ commandName = 'workflows';
28
+ appSlugById = {};
29
+ skippedApps = [];
30
+ detailRef;
31
+ async run() {
32
+ const { args, flags } = await this.parse(Workflows);
33
+ await this.runInspect({ args, flags });
34
+ }
35
+ keyFor(record, appSlugById) {
36
+ const appId = record.app_id || '';
37
+ const appPart = appSlugById[appId] || appId;
38
+ return `app.${appPart}.${workflowDisplayName(record)}`;
39
+ }
40
+ metaFor(record) {
41
+ return workflowListMeta(record);
42
+ }
43
+ async getListResults(scope, _flags) {
44
+ this.skippedApps = [];
45
+ if (scope.appId) {
46
+ return this.getWorkflowListForApp(scope.appId);
47
+ }
48
+ this.appSlugById = await buildAppSlugMap(this.api);
49
+ const results = await Promise.all(Object.keys(this.appSlugById).map(async (appId) => {
50
+ try {
51
+ return await this.getWorkflowListForApp(appId);
52
+ }
53
+ catch {
54
+ this.skippedApps.push(this.appSlugById[appId] || appId);
55
+ return [];
56
+ }
57
+ }));
58
+ return results.flat();
59
+ }
60
+ async getAppSlugById(scope) {
61
+ if (scope.appId) {
62
+ return scope.appSlug ? { [scope.appId]: scope.appSlug } : {};
63
+ }
64
+ return this.appSlugById;
65
+ }
66
+ afterList() {
67
+ if (this.skippedApps.length > 0) {
68
+ this.log();
69
+ this.log(`Skipped inaccessible apps: ${this.skippedApps.join(', ')}`);
70
+ }
71
+ }
72
+ async showDetail(identifier, _scope, flags) {
73
+ const ref = await resolveWorkflowOperationRef(this.api, identifier, flags);
74
+ const workflowRef = ref.query.workflow_id || ref.query.workflow_name;
75
+ const detail = await this.api.get({ adminPath: `/data/:workflows/${workflowRef}` }, { query: { app_id: ref.appId } });
76
+ const manifest = detail?.manifest || {};
77
+ const workflowName = workflowDisplayName(manifest);
78
+ this.detailRef = {
79
+ appPart: ref.appSlug || ref.appId,
80
+ workflowName,
81
+ };
82
+ this.emitDetail(redactWorkflowPayload(detail), flags);
83
+ }
84
+ hints() {
85
+ if (!this.detailRef) {
86
+ return [];
87
+ }
88
+ const { appPart, workflowName } = this.detailRef;
89
+ return [
90
+ `swell inspect workflow-runs --workflow app.${appPart}.${workflowName} --status active`,
91
+ `swell inspect workflow-runs --workflow app.${appPart}.${workflowName} --status failed`,
92
+ `swell logs --type workflow --app ${appPart} -s ${workflowName}`,
93
+ ];
94
+ }
95
+ async getWorkflowListForApp(appId) {
96
+ const response = await this.api.get({ adminPath: '/data/:workflows' }, { query: { app_id: appId } });
97
+ return response?.results || [];
98
+ }
99
+ }
@@ -20,4 +20,5 @@ export default class Logs extends SwellCommand {
20
20
  run(): Promise<void>;
21
21
  private getLogs;
22
22
  private getLogsAndWriteToStream;
23
+ private resolveWorkflowLogFilters;
23
24
  }
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { LineOutput, LoggedItem, TableOutput } from '../lib/logs/index.js';
3
+ import { resolveWorkflowScope } from '../lib/workflows/operations.js';
3
4
  import { SwellCommand } from '../swell-command.js';
4
5
  // the columns available to display in the table
5
6
  const OUTPUT_COLUMNS = [
@@ -104,6 +105,7 @@ export default class Logs extends SwellCommand {
104
105
  'swell logs',
105
106
  'swell logs -f -p -n 10 -c date,req,data',
106
107
  'swell logs -s accounts',
108
+ 'swell logs --type workflow --app my-app -s run-import',
107
109
  'swell logs --after 2023-08-25T16:01:02.697Z -c date',
108
110
  'swell logs --after 2023-08-27',
109
111
  ];
@@ -115,7 +117,7 @@ export default class Logs extends SwellCommand {
115
117
  }),
116
118
  // filters
117
119
  app: Flags.string({
118
- description: 'filter logs by app id',
120
+ description: 'filter logs by app id, slug, or "." for current swell.json',
119
121
  }),
120
122
  before: Flags.string({
121
123
  description: 'expected is Javascript Date Time String Format: YYYY-MM-DDTHH:mm:ss.sssZ',
@@ -175,13 +177,14 @@ export default class Logs extends SwellCommand {
175
177
  type: Flags.string({
176
178
  description: 'filter logs by type',
177
179
  multiple: true,
178
- options: ['api', 'function', 'webhook', 'transaction'],
180
+ options: ['api', 'function', 'webhook', 'transaction', 'workflow'],
179
181
  }),
180
182
  };
181
183
  static summary = 'Output or stream store logs to the terminal.';
182
184
  output;
183
185
  async run() {
184
186
  const { flags } = await this.parse(Logs);
187
+ await this.resolveWorkflowLogFilters(flags);
185
188
  // indentify the columns to display
186
189
  const columns = flags.columns.split(',');
187
190
  // create the output stream
@@ -229,4 +232,19 @@ export default class Logs extends SwellCommand {
229
232
  // if the user is following logs, we want to return the last date we received
230
233
  return lastDate || flags.startPolling || new Date().toISOString();
231
234
  }
235
+ async resolveWorkflowLogFilters(flags) {
236
+ if (!flags.type?.includes('workflow') || !flags.app) {
237
+ return;
238
+ }
239
+ const envId = flags.env || 'test';
240
+ if (envId !== 'live') {
241
+ await this.api.setEnv(envId);
242
+ }
243
+ const scope = await resolveWorkflowScope(this.api, {
244
+ app: flags.app,
245
+ });
246
+ if (scope.appId) {
247
+ flags.app = scope.appId;
248
+ }
249
+ }
232
250
  }
@@ -7,6 +7,7 @@ import * as fs from 'node:fs';
7
7
  import parseJson from 'parse-json';
8
8
  import { bundleFunction } from '../lib/bundle.js';
9
9
  import { SCHEMAS_BASE_URL } from '../lib/constants.js';
10
+ import { analyzeFunctionSource, getFunctionTriggers, hasKindDiagnostic, workflowStaticConfigError, } from '../lib/function-source-analysis.js';
10
11
  import { SwellCommand } from '../swell-command.js';
11
12
  const SCHEMA_DEFINITIONS = Object.freeze({
12
13
  model: {
@@ -25,7 +26,7 @@ const SCHEMA_DEFINITIONS = Object.freeze({
25
26
  description: 'Webhook definitions (/webhooks/*.json)',
26
27
  },
27
28
  function: {
28
- description: 'Function definitions (/functions/*.ts or *.js)',
29
+ description: 'Function and workflow definitions (/functions/*.ts or *.js)',
29
30
  hasJsonSchema: false,
30
31
  },
31
32
  });
@@ -60,6 +61,10 @@ export default class Schema extends SwellCommand {
60
61
  description: 'Validate a function file',
61
62
  command: '<%= config.bin %> <%= command.id %> function myfunction.ts',
62
63
  },
64
+ {
65
+ description: 'Validate a workflow file',
66
+ command: '<%= config.bin %> <%= command.id %> function run-import.ts',
67
+ },
63
68
  ];
64
69
  static args = {
65
70
  type: Args.string({
@@ -225,6 +230,17 @@ export default class Schema extends SwellCommand {
225
230
  throw new Error(`File not found: ${file}`);
226
231
  }
227
232
  const filePath = fs.realpathSync(file);
233
+ const analysis = await analyzeFunctionSource(filePath);
234
+ if (analysis.kind === 'workflow') {
235
+ if (analysis.diagnostics.length > 0) {
236
+ throw new Error(`Invalid workflow function:\n• ${analysis.diagnostics.join('\n• ')}`);
237
+ }
238
+ this.log('Valid workflow function definition');
239
+ return;
240
+ }
241
+ if (hasKindDiagnostic(analysis)) {
242
+ throw new Error(`Invalid function config:\n• ${analysis.diagnostics.join('\n• ')}`);
243
+ }
228
244
  // Attempt to bundle the function (validates syntax, imports, etc.)
229
245
  let bundleResult;
230
246
  try {
@@ -234,12 +250,15 @@ export default class Schema extends SwellCommand {
234
250
  throw new Error(`Function compilation failed:\n• ${error.message}`);
235
251
  }
236
252
  const { config } = bundleResult;
253
+ if (config?.kind === 'workflow') {
254
+ throw workflowStaticConfigError();
255
+ }
237
256
  // Validate config exists
238
257
  if (!config) {
239
258
  throw new Error('Invalid function:\n• Function must export a `config` object');
240
259
  }
241
260
  // Validate trigger exclusivity
242
- const triggers = ['route', 'model', 'cron'].filter((t) => config[t]);
261
+ const triggers = getFunctionTriggers(config);
243
262
  if (triggers.length === 0) {
244
263
  throw new Error('Invalid function config:\n• Config must specify one of: route, model, cron');
245
264
  }
@@ -488,7 +488,7 @@ export class CreateAppCommand extends SwellCommand {
488
488
  const packageJson = {
489
489
  description: config.get('description'),
490
490
  devDependencies: {
491
- '@swell/app-types': '^1.0.5',
491
+ '@swell/app-types': '^1.2.0',
492
492
  typescript: '^5.9.3',
493
493
  },
494
494
  name,
@@ -67,7 +67,10 @@ export declare abstract class InspectResourceCommand extends SwellCommand {
67
67
  protected groupForRecord(record: any, appSlugById: Record<string, string>): GroupInfo;
68
68
  protected catch(error: Error): Promise<any>;
69
69
  protected runInspect(parsed: InspectResourceCommandParsed): Promise<void>;
70
- private showList;
70
+ protected showList(scope: InspectScope, flags: InspectResourceCommandParsed['flags']): Promise<void>;
71
+ protected getListResults(scope: InspectScope, _flags: InspectResourceCommandParsed['flags']): Promise<any[]>;
72
+ protected getAppSlugById(scope: InspectScope): Promise<Record<string, string>>;
73
+ protected afterList(_scope: InspectScope, _flags: InspectResourceCommandParsed['flags']): void;
71
74
  protected showDetail(identifier: string, scope: InspectScope, flags: InspectResourceCommandParsed['flags']): Promise<void>;
72
75
  /**
73
76
  * Per-subclass detail-mode hints. Return runnable commands pointing to
@@ -120,13 +120,9 @@ export class InspectResourceCommand extends SwellCommand {
120
120
  await this.showList(scope, flags);
121
121
  }
122
122
  async showList(scope, flags) {
123
- const { results } = await this.api.getAll({ adminPath: this.adminPath }, { query: scope.query });
124
- const filtered = this.filterListResults(results ?? []);
125
- const appSlugById = scope.appId
126
- ? scope.appSlug
127
- ? { [scope.appId]: scope.appSlug }
128
- : {}
129
- : await buildAppSlugMap(this.api);
123
+ const results = await this.getListResults(scope, flags);
124
+ const filtered = this.filterListResults(results);
125
+ const appSlugById = await this.getAppSlugById(scope);
130
126
  this.printPreamble(flags.live ?? false);
131
127
  if (filtered.length === 0) {
132
128
  this.log();
@@ -143,10 +139,22 @@ export class InspectResourceCommand extends SwellCommand {
143
139
  for (const line of renderKeyMetaTable(rows)) {
144
140
  this.log(line);
145
141
  }
142
+ this.afterList(scope, flags);
146
143
  this.log();
147
144
  this.log(`Run "swell inspect ${this.commandName} <key>" to view a ${this.resourceLabelSingular}.`);
148
145
  this.log();
149
146
  }
147
+ async getListResults(scope, _flags) {
148
+ const { results } = await this.api.getAll({ adminPath: this.adminPath }, { query: scope.query });
149
+ return results ?? [];
150
+ }
151
+ async getAppSlugById(scope) {
152
+ if (scope.appId) {
153
+ return scope.appSlug ? { [scope.appId]: scope.appSlug } : {};
154
+ }
155
+ return buildAppSlugMap(this.api);
156
+ }
157
+ afterList(_scope, _flags) { }
150
158
  async showDetail(identifier, scope, flags) {
151
159
  const kind = classifyIdentifier(identifier);
152
160
  let record = null;
package/dist/lib/api.js CHANGED
@@ -12,6 +12,12 @@ export var HttpMethod;
12
12
  HttpMethod["DELETE"] = "delete";
13
13
  })(HttpMethod || (HttpMethod = {}));
14
14
  const GET_ALL_LIMIT = 1000;
15
+ function isHtmlResponse(contentType, data) {
16
+ if (contentType?.toLowerCase().includes('text/html')) {
17
+ return true;
18
+ }
19
+ return /^\s*<(?:!doctype\s+html|html)[\s>]/i.test(data);
20
+ }
15
21
  const defaultHeaders = (opts) => ({
16
22
  'Content-Type': 'application/json',
17
23
  ...opts,
@@ -84,6 +90,12 @@ export default class Api {
84
90
  return res;
85
91
  }
86
92
  const resData = await res.text();
93
+ const contentType = res.headers.get('content-type');
94
+ if (isHtmlResponse(contentType, resData)) {
95
+ const error = new Error(`Expected JSON from API route '${path}', but received HTML. This usually means the admin API proxy did not match the route.`);
96
+ error.status = res.status;
97
+ throw error;
98
+ }
87
99
  if (!res.ok) {
88
100
  // Try to parse error message
89
101
  let errorJson;