@sanity/runtime-cli 17.7.1 → 17.8.0-next.g5972da4

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.
@@ -1,39 +1,78 @@
1
1
  import { listStacks } from '../../actions/blueprints/stacks.js';
2
- import { listProjects } from '../../actions/sanity/projects.js';
2
+ import { listOrganizations, listProjects, } from '../../actions/sanity/projects.js';
3
+ import { CI_SETUP_DOCS_URL } from '../../constants.js';
3
4
  import { formatStacksListing } from '../../utils/display/blueprints-formatting.js';
4
5
  import { capitalize, niceId, warn } from '../../utils/display/presenters.js';
6
+ import { promptForOrganization, promptForProjectInOrg, promptForScope, promptForScopeRecovery, } from '../../utils/display/prompt.js';
7
+ import { isInteractive } from '../../utils/interactive.js';
5
8
  import { styleText } from '../../utils/style-text.js';
6
- /** Max projects to query for stacks during --include-projects */
9
+ /** Max projects to query for stacks within one organization */
7
10
  const PROJECT_LIMIT = 50;
11
+ /** Max projects to query for stacks across every organization */
12
+ const ALL_PROJECTS_LIMIT = 200;
13
+ /** Stack listings in flight while querying many scopes */
14
+ const SCOPE_CONCURRENCY = 5;
8
15
  export async function blueprintStacksCore(options) {
9
- const { log, token, scopeType, scopeId, localStackId: blueprintStackId, flags } = options;
10
- const { 'include-projects': includeProjects = false } = flags;
11
- if (includeProjects && scopeType === 'organization') {
12
- return deepListStacks({ organizationId: scopeId, token, log, blueprintStackId });
13
- }
16
+ const { bin, log, token, localStackId: blueprintStackId, flags } = options;
17
+ const { all = false, 'include-projects': includeProjects = false, json = false } = flags;
14
18
  try {
15
- const spinner = log.ora('Fetching Stacks...').start();
16
- const { ok, stacks, error } = await listStacks({ token, scopeType, scopeId }, log);
17
- if (!ok) {
18
- spinner.fail('Failed to list Stacks');
19
- const suggestions = [];
20
- if (error && /access|permission|forbidden/i.test(error)) {
21
- suggestions.push('Verify you have access to this organization or project.', `Try a different scope with --project-id or --organization-id.`);
19
+ if (all)
20
+ return await listStacksEverywhere({ token, log, blueprintStackId });
21
+ let { scopeType, scopeId } = options;
22
+ let displayName;
23
+ let promptedForScope = false;
24
+ let hasAlternativeScope = false;
25
+ while (true) {
26
+ if (!scopeType || !scopeId) {
27
+ if (!isInteractive || json)
28
+ return missingScopeResult(bin);
29
+ const chosen = await chooseScope({ token, log });
30
+ scopeType = chosen.scopeType;
31
+ scopeId = chosen.scopeId;
32
+ displayName = chosen.displayName;
33
+ hasAlternativeScope = chosen.hasAlternativeScope;
34
+ promptedForScope = true;
22
35
  }
23
- return {
24
- success: false,
25
- error: error || 'Failed to list stacks',
26
- suggestions: suggestions.length ? suggestions : undefined,
27
- };
28
- }
29
- spinner.stop().clear();
30
- if (!stacks || stacks.length === 0) {
31
- log('No stacks found');
32
- return { success: true };
36
+ if (includeProjects && scopeType === 'organization') {
37
+ return await listStacksAcrossOrganization({
38
+ organizationId: scopeId,
39
+ token,
40
+ log,
41
+ blueprintStackId,
42
+ });
43
+ }
44
+ const spinner = log.ora('Fetching Stacks...').start();
45
+ const result = await listStacks({ token, scopeType, scopeId }, log);
46
+ if (!result.ok) {
47
+ if (promptedForScope && hasAlternativeScope && result.response?.status === 403) {
48
+ spinner.stop().clear();
49
+ log(warn(`Your account can see ${scopeTargetLabel({ scopeType, scopeId, displayName })}, but cannot read its Blueprint Stacks.`));
50
+ const recovery = await promptForScopeRecovery();
51
+ if (recovery === 'retry') {
52
+ scopeType = undefined;
53
+ scopeId = undefined;
54
+ displayName = undefined;
55
+ hasAlternativeScope = false;
56
+ continue;
57
+ }
58
+ }
59
+ else {
60
+ spinner.fail('Failed to list Stacks');
61
+ }
62
+ return listStacksFailure(result.error, result.response?.status);
63
+ }
64
+ spinner.stop().clear();
65
+ if (result.stacks.length === 0) {
66
+ log('No stacks found');
67
+ return { success: true, json: { stacks: [], scopeType, scopeId } };
68
+ }
69
+ printStackGroups({
70
+ groups: [{ scopeType, scopeId, displayName, stacks: result.stacks }],
71
+ log,
72
+ blueprintStackId,
73
+ });
74
+ return { success: true, json: { stacks: result.stacks, scopeType, scopeId } };
33
75
  }
34
- log(`${styleText('bold', capitalize(scopeType))} ${niceId(scopeId)} ${styleText('bold', 'Stacks')}:\n`);
35
- log(formatStacksListing(stacks, blueprintStackId));
36
- return { success: true, json: { stacks, scopeType, scopeId } };
37
76
  }
38
77
  catch (error) {
39
78
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -41,71 +80,229 @@ export async function blueprintStacksCore(options) {
41
80
  return { success: false, error: errorMessage };
42
81
  }
43
82
  }
44
- async function deepListStacks(options) {
83
+ function listStacksFailure(error, status) {
84
+ return {
85
+ success: false,
86
+ error: error || 'Failed to list stacks',
87
+ suggestions: status === 403
88
+ ? [
89
+ 'Ask an administrator to grant you read access to Blueprint Stacks for this organization or project.',
90
+ 'Try a different scope with --project-id or --organization-id.',
91
+ ]
92
+ : undefined,
93
+ };
94
+ }
95
+ function missingScopeResult(bin) {
96
+ return {
97
+ success: false,
98
+ error: 'Missing scope: provide --project-id or --organization-id.',
99
+ suggestions: [
100
+ `Run \`${bin} blueprints stacks --all\` to list Stacks for every organization and project.`,
101
+ 'Set SANITY_PROJECT_ID or SANITY_ORGANIZATION_ID when no terminal is available.',
102
+ ],
103
+ ref: CI_SETUP_DOCS_URL,
104
+ };
105
+ }
106
+ /** Organization, then project when the user picks project scope. */
107
+ async function chooseScope(options) {
108
+ const { token, log } = options;
109
+ const orgResult = await promptForOrganization({ token, logger: log });
110
+ if (typeof orgResult === 'symbol')
111
+ throw new Error('Unexpected back navigation');
112
+ const scopeType = await promptForScope({
113
+ hasProjects: orgResult.projects.length > 0,
114
+ message: 'Which Stacks would you like to list?',
115
+ });
116
+ if (typeof scopeType === 'symbol')
117
+ throw new Error('Unexpected back navigation');
118
+ if (scopeType === 'organization') {
119
+ const { id, name } = orgResult.organization;
120
+ return {
121
+ scopeType,
122
+ scopeId: id,
123
+ displayName: name,
124
+ hasAlternativeScope: orgResult.wasPrompted || orgResult.projects.length > 0,
125
+ };
126
+ }
127
+ const project = await promptForProjectInOrg({ projects: orgResult.projects, logger: log });
128
+ if (typeof project === 'symbol')
129
+ throw new Error('Unexpected back navigation');
130
+ return {
131
+ scopeType,
132
+ scopeId: project.projectId,
133
+ displayName: project.displayName,
134
+ // The containing organization is another possible scope even when it has no other projects.
135
+ hasAlternativeScope: true,
136
+ };
137
+ }
138
+ async function listStacksAcrossOrganization(options) {
45
139
  const { organizationId, token, log, blueprintStackId } = options;
46
- const allStacks = [];
47
140
  const spinner = log.ora('Fetching Stacks...').start();
48
- try {
49
- // 1. Org-scoped stacks
50
- const orgResult = await listStacks({ token, scopeType: 'organization', scopeId: organizationId }, log);
51
- const orgStacks = orgResult.ok ? orgResult.stacks : [];
52
- // 2. Get projects in the organization
53
- const projectsResult = await listProjects({ token, organizationId, logger: log });
54
- if (!projectsResult.ok) {
55
- spinner.fail('Failed to list projects');
56
- return { success: false, error: projectsResult.error || 'Failed to list projects' };
141
+ const projectsResult = await listProjects({ token, organizationId, logger: log });
142
+ if (!projectsResult.ok) {
143
+ spinner.fail('Failed to list projects');
144
+ return { success: false, error: projectsResult.error || 'Failed to list projects' };
145
+ }
146
+ const projects = projectsResult.projects;
147
+ const targets = [
148
+ { scopeType: 'organization', scopeId: organizationId },
149
+ ...projectTargets(projects.slice(0, PROJECT_LIMIT)),
150
+ ];
151
+ const { groups, failures, successfulScopes } = await collectStackGroups({
152
+ targets,
153
+ token,
154
+ log,
155
+ onProgress: (completed, total) => {
156
+ spinner.text = `Fetching Stacks... (${completed}/${total})`;
157
+ },
158
+ });
159
+ if (successfulScopes === 0 && failures.length > 0) {
160
+ spinner.fail('Failed to list Stacks');
161
+ return listStacksFailure(failures[0].error, failures[0].status);
162
+ }
163
+ spinner.stop().clear();
164
+ const stacks = groups.flatMap((group) => group.stacks);
165
+ if (stacks.length > 0)
166
+ printStackGroups({ groups, log, blueprintStackId });
167
+ else
168
+ log('No stacks found');
169
+ const organizationFailure = failures.find(({ target }) => target.scopeType === 'organization');
170
+ if (organizationFailure) {
171
+ log(warn(`Could not read organization-scoped Stacks: ${organizationFailure.error}`));
172
+ }
173
+ const projectFailures = failures.filter(({ target }) => target.scopeType === 'project');
174
+ if (projectFailures.length > 0) {
175
+ const projectCount = targets.filter(({ scopeType }) => scopeType === 'project').length;
176
+ log(warn(`Could not read Stacks for ${projectFailures.length} of ${projectCount} projects.`));
177
+ for (const { target, error } of projectFailures) {
178
+ log.verbose(`Project ${target.scopeId}: ${error}`);
57
179
  }
58
- const projects = projectsResult.projects;
59
- const totalProjects = projects.length;
60
- const cappedProjects = projects.slice(0, PROJECT_LIMIT);
61
- // 3. Fetch stacks for each project
62
- const projectGroups = [];
63
- for (let i = 0; i < cappedProjects.length; i++) {
64
- const project = cappedProjects[i];
65
- spinner.text = `Fetching Stacks... (project ${i + 1}/${cappedProjects.length})`;
66
- const result = await listStacks({ token, scopeType: 'project', scopeId: project.id }, log);
67
- if (result.ok && result.stacks.length > 0) {
68
- projectGroups.push({
69
- projectId: project.id,
70
- displayName: project.displayName,
71
- stacks: result.stacks,
180
+ }
181
+ if (projects.length > PROJECT_LIMIT)
182
+ log(projectLimitWarning(projects.length, PROJECT_LIMIT));
183
+ return { success: true, json: { stacks, scopeType: 'organization', scopeId: organizationId } };
184
+ }
185
+ /** Every organization and project the token can reach, skipping the scopes it cannot read. */
186
+ async function listStacksEverywhere(options) {
187
+ const { token, log, blueprintStackId } = options;
188
+ const spinner = log.ora('Finding organizations and projects...').start();
189
+ const [orgsResult, projectsResult] = await Promise.all([
190
+ listOrganizations({ token, logger: log }),
191
+ listProjects({ token, logger: log }),
192
+ ]);
193
+ if (!orgsResult.ok && !projectsResult.ok) {
194
+ spinner.fail('Failed to list organizations and projects');
195
+ return {
196
+ success: false,
197
+ error: orgsResult.error || projectsResult.error || 'Failed to list organizations',
198
+ };
199
+ }
200
+ const discoveryWarnings = [];
201
+ if (!orgsResult.ok) {
202
+ discoveryWarnings.push(`Could not list organizations: ${orgsResult.error || 'unknown error'}`);
203
+ }
204
+ if (!projectsResult.ok) {
205
+ discoveryWarnings.push(`Could not list projects: ${projectsResult.error || 'unknown error'}`);
206
+ }
207
+ const organizations = orgsResult.ok ? orgsResult.organizations : [];
208
+ const projects = projectsResult.ok ? projectsResult.projects : [];
209
+ const targets = buildScopeTargets(organizations, projects.slice(0, ALL_PROJECTS_LIMIT));
210
+ spinner.text = 'Fetching Stacks...';
211
+ const { groups, failures, successfulScopes } = await collectStackGroups({
212
+ targets,
213
+ token,
214
+ log,
215
+ onProgress: (completed, total) => {
216
+ spinner.text = `Fetching Stacks... (${completed}/${total})`;
217
+ },
218
+ });
219
+ if (targets.length > 0 && successfulScopes === 0) {
220
+ spinner.fail('Failed to list Stacks');
221
+ return listStacksFailure(failures[0]?.error, failures[0]?.status);
222
+ }
223
+ spinner.stop().clear();
224
+ const stacks = groups.flatMap((group) => group.stacks);
225
+ if (stacks.length > 0)
226
+ printStackGroups({ groups, log, blueprintStackId });
227
+ else if (targets.length === 0)
228
+ log('No organizations or projects found');
229
+ else
230
+ log('No stacks found');
231
+ for (const message of discoveryWarnings)
232
+ log(warn(message));
233
+ if (projects.length > ALL_PROJECTS_LIMIT) {
234
+ log(projectLimitWarning(projects.length, ALL_PROJECTS_LIMIT));
235
+ }
236
+ if (failures.length > 0) {
237
+ log(warn(`Could not read Stacks for ${failures.length} of ${targets.length} scopes.`));
238
+ for (const { target, error } of failures) {
239
+ log.verbose(`${capitalize(target.scopeType)} ${target.scopeId}: ${error}`);
240
+ }
241
+ }
242
+ return { success: true, json: { stacks } };
243
+ }
244
+ async function collectStackGroups(options) {
245
+ const { targets, token, log, onProgress } = options;
246
+ const groups = [];
247
+ const failures = [];
248
+ let completed = 0;
249
+ let successfulScopes = 0;
250
+ for (let start = 0; start < targets.length; start += SCOPE_CONCURRENCY) {
251
+ const batch = await Promise.all(targets.slice(start, start + SCOPE_CONCURRENCY).map(async (target) => {
252
+ const { scopeType, scopeId } = target;
253
+ const result = await listStacks({ token, scopeType, scopeId }, log);
254
+ completed += 1;
255
+ onProgress?.(completed, targets.length);
256
+ return { target, result };
257
+ }));
258
+ for (const { target, result } of batch) {
259
+ if (!result.ok) {
260
+ failures.push({
261
+ target,
262
+ error: result.error || 'Failed to list stacks',
263
+ status: result.response?.status,
72
264
  });
73
- allStacks.push(...result.stacks);
74
265
  }
75
- else if (!result.ok) {
76
- throw new Error(result.error);
266
+ else {
267
+ successfulScopes += 1;
268
+ if (result.stacks.length > 0)
269
+ groups.push({ ...target, stacks: result.stacks });
77
270
  }
78
271
  }
79
- spinner.stop().clear();
80
- // 4. Print results
81
- if (orgStacks.length > 0) {
82
- log(`${styleText('bold', 'Organization')} ${niceId(organizationId)} ${styleText('bold', 'Stacks')}:\n`);
83
- log(formatStacksListing(orgStacks, blueprintStackId));
84
- if (projectGroups.length > 0)
85
- log(''); // blank line between sections
86
- }
87
- for (const group of projectGroups) {
88
- log(`${styleText('bold', 'Project')} "${group.displayName}" ${niceId(group.projectId)} ${styleText('bold', 'Stacks')}:`);
89
- log(formatStacksListing(group.stacks, blueprintStackId));
90
- log('');
91
- }
92
- if (totalProjects > PROJECT_LIMIT) {
93
- log(warn(`Organization has ${totalProjects} projects, only checked the first ${PROJECT_LIMIT}.`));
94
- }
95
- if (orgStacks.length === 0 && allStacks.length === 0) {
96
- log('No stacks found');
97
- return { success: true };
98
- }
99
- allStacks.push(...orgStacks);
100
- return {
101
- success: true,
102
- json: { stacks: allStacks, scopeType: 'organization', scopeId: organizationId },
103
- };
104
272
  }
105
- catch (error) {
106
- spinner.stop().clear();
107
- const errorMessage = error instanceof Error ? error.message : String(error);
108
- log.error(`Error: ${errorMessage}`);
109
- return { success: false, error: errorMessage };
273
+ return { groups, failures, successfulScopes };
274
+ }
275
+ function buildScopeTargets(organizations, projects) {
276
+ const organizationIds = new Set(organizations.map(({ id }) => id));
277
+ const targets = [];
278
+ for (const { id, name } of organizations) {
279
+ targets.push({ scopeType: 'organization', scopeId: id, displayName: name });
280
+ targets.push(...projectTargets(projects.filter((p) => p.organizationId === id)));
110
281
  }
282
+ targets.push(...projectTargets(projects.filter((p) => !organizationIds.has(p.organizationId))));
283
+ return targets;
284
+ }
285
+ function projectTargets(projects) {
286
+ return projects.map(({ id, displayName }) => ({
287
+ scopeType: 'project',
288
+ scopeId: id,
289
+ displayName,
290
+ }));
291
+ }
292
+ function scopeTargetLabel({ scopeType, scopeId, displayName }) {
293
+ const name = displayName ? ` "${displayName}"` : '';
294
+ return `${scopeType}${name} ${niceId(scopeId)}`;
295
+ }
296
+ function printStackGroups(options) {
297
+ const { groups, log, blueprintStackId } = options;
298
+ groups.forEach((group, index) => {
299
+ const name = group.displayName ? ` "${group.displayName}"` : '';
300
+ if (index > 0)
301
+ log('');
302
+ log(`${styleText('bold', capitalize(group.scopeType))}${name} ${niceId(group.scopeId)} ${styleText('bold', 'Stacks')}:\n`);
303
+ log(formatStacksListing(group.stacks, blueprintStackId));
304
+ });
305
+ }
306
+ function projectLimitWarning(total, limit) {
307
+ return warn(`Found ${total} projects, only checked the first ${limit}.`);
111
308
  }
@@ -1,29 +1,12 @@
1
1
  import type { ReadBlueprintResult } from '../../actions/blueprints/blueprint.js';
2
+ import { type FunctionTestFlags } from '../../utils/types.js';
2
3
  import type { CoreConfig, CoreResult } from '../index.js';
3
4
  export interface FunctionTestOptions extends CoreConfig {
4
5
  blueprint: ReadBlueprintResult;
5
6
  args: {
6
7
  name: string | undefined;
7
8
  };
8
- flags: {
9
- data?: string;
10
- event?: string;
11
- file?: string;
12
- timeout?: number;
13
- api?: string;
14
- dataset?: string;
15
- 'project-id'?: string;
16
- 'organization-id'?: string;
17
- 'document-id'?: string;
18
- 'with-user-token'?: boolean;
19
- 'data-before'?: string;
20
- 'data-after'?: string;
21
- 'file-before'?: string;
22
- 'file-after'?: string;
23
- 'document-id-before'?: string;
24
- 'document-id-after'?: string;
25
- 'media-library-id'?: string;
26
- };
9
+ flags: FunctionTestFlags;
27
10
  error: (message: string, options: object) => void;
28
11
  helpText: string;
29
12
  }
@@ -1,20 +1,30 @@
1
- import { readFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import { cwd } from 'node:process';
1
+ import { dirname } from 'node:path';
4
2
  import { testAction } from '../../actions/functions/test.js';
5
3
  import config from '../../config.js';
6
- import { SANITY_FUNCTION_DOCUMENT, SANITY_FUNCTION_MEDIA_LIBRARY_ASSET } from '../../constants.js';
4
+ import { STEP_PHASE_GLYPH } from '../../constants.js';
7
5
  import { resolveDeployedResources } from '../../utils/blueprints/resolve-deployed-resources.js';
8
- import buildPayload from '../../utils/functions/build-payload.js';
9
- import { fetchAsset, fetchDocument } from '../../utils/functions/fetch-document.js';
10
6
  import { findFunctionInResources, getFunctionNames } from '../../utils/functions/find.js';
11
7
  import { runFunction } from '../../utils/functions/invoke/nested.js';
12
- import { parseJsonObject } from '../../utils/parse-json-object.js';
8
+ import { buildContext, buildInvokeOptions, collectInvokeInput, defaultInvocationDeps, resolveInvocationPlan, } from '../../utils/functions/invoke/resolve-invoke-type.js';
13
9
  import { isEventType, } from '../../utils/types.js';
10
+ /**
11
+ * CLI lines for a durable run, in emission order; bare `start` events dropped.
12
+ * @param events
13
+ */
14
+ function renderStepTimeline(events) {
15
+ return events
16
+ .filter((event) => event.phase !== 'start')
17
+ .map((event) => {
18
+ const glyph = STEP_PHASE_GLYPH[event.phase] ?? '•';
19
+ const duration = typeof event.durationMs === 'number' ? ` (${Math.round(event.durationMs)}ms)` : '';
20
+ const detail = event.phase === 'error' && event.error ? ` ${event.error.message}` : '';
21
+ return ` ${glyph} ${event.name} [${event.kind}]${duration}${detail}`;
22
+ });
23
+ }
14
24
  export async function functionTestCore(options) {
15
25
  const { blueprint, log, error, args, flags, helpText } = options;
16
26
  const { name: fnName } = args;
17
- const { data, event, file, timeout, api, dataset, 'document-id': documentId, 'with-user-token': withUserToken, 'data-before': dataBefore, 'data-after': dataAfter, 'file-before': fileBefore, 'file-after': fileAfter, 'document-id-before': documentIdBefore, 'document-id-after': documentIdAfter, 'media-library-id': mediaLibraryId, } = flags;
27
+ const { event, timeout, api, 'with-user-token': withUserToken } = flags;
18
28
  const { 'project-id': projectId = blueprint?.projectId, 'organization-id': organizationId = blueprint?.organizationId, } = flags;
19
29
  if (!fnName) {
20
30
  const functionNames = getFunctionNames(blueprint.resources);
@@ -40,8 +50,6 @@ export async function functionTestCore(options) {
40
50
  }
41
51
  try {
42
52
  const resource = findFunctionInResources(blueprint.resources, fnName); // throws if not found
43
- const docFunction = resource.type === SANITY_FUNCTION_DOCUMENT;
44
- const mediaFunction = resource.type === SANITY_FUNCTION_MEDIA_LIBRARY_ASSET;
45
53
  const resolvedResources = await resolveDeployedResources({
46
54
  resources: blueprint.resources,
47
55
  stackId: blueprint.stackId,
@@ -50,108 +58,31 @@ export async function functionTestCore(options) {
50
58
  : undefined,
51
59
  logger: log,
52
60
  });
53
- const blueprintFileDir = dirname(blueprint.fileInfo.blueprintFilePath);
54
- const contextOptions = docFunction || mediaFunction
55
- ? {
56
- clientOptions: {
57
- apiVersion: api,
58
- dataset,
59
- projectId,
60
- organizationId,
61
- },
62
- eventResourceType: docFunction ? 'dataset' : 'media-library',
63
- eventResourceId: (docFunction ? `${projectId}.${dataset}` : mediaLibraryId) || '',
64
- functionResourceType: 'project',
65
- functionResourceId: projectId || '',
66
- resources: resolvedResources,
67
- blueprintFileDir,
68
- }
69
- : { clientOptions: {}, resources: resolvedResources, blueprintFileDir };
70
- // If the user sets the flag to use the real token set it in our options
71
- if (withUserToken) {
72
- contextOptions.clientOptions.token = config.token || undefined;
73
- }
74
- let before = null;
75
- let after = null;
76
- if (dataBefore && dataAfter) {
77
- before = parseJsonObject(dataBefore);
78
- after = parseJsonObject(dataAfter);
79
- }
80
- else if (fileBefore && fileAfter) {
81
- let fileContents = readFileSync(join(cwd(), fileBefore), 'utf8');
82
- before = parseJsonObject(fileContents);
83
- fileContents = readFileSync(join(cwd(), fileAfter), 'utf8');
84
- after = parseJsonObject(fileContents);
85
- }
86
- else if (documentIdBefore && documentIdAfter && docFunction) {
87
- before = await fetchDocument(documentIdBefore, {
88
- projectId,
89
- dataset,
90
- apiVersion: api,
91
- apiHost: config.apiUrl,
92
- token: config.token || undefined,
93
- }, log);
94
- after = await fetchDocument(documentIdAfter, {
95
- projectId,
96
- dataset,
97
- apiVersion: api,
98
- apiHost: config.apiUrl,
99
- token: config.token || undefined,
100
- }, log);
101
- }
102
- else if (documentIdBefore && documentIdAfter) {
103
- before = await fetchAsset(documentIdBefore, {
104
- mediaLibraryId,
105
- apiVersion: api,
106
- apiHost: config.apiUrl,
107
- token: config.token || undefined,
108
- }, log);
109
- after = await fetchAsset(documentIdAfter, {
110
- mediaLibraryId,
111
- apiVersion: api,
112
- apiHost: config.apiUrl,
113
- token: config.token || undefined,
114
- }, log);
115
- }
116
- let payload;
117
- if (after) {
118
- payload = after;
119
- }
120
- else {
121
- if (documentId && docFunction) {
122
- payload = await fetchDocument(documentId, {
123
- projectId,
124
- dataset,
125
- apiVersion: api,
126
- apiHost: config.apiUrl,
127
- token: config.token || undefined,
128
- }, log);
129
- }
130
- else if (documentId) {
131
- payload = await fetchAsset(documentId, {
132
- mediaLibraryId,
133
- apiVersion: api,
134
- apiHost: config.apiUrl,
135
- token: config.token || undefined,
136
- }, log);
137
- }
138
- else {
139
- payload = buildPayload({ data, file });
140
- }
141
- }
142
- const invokeOptions = docFunction || mediaFunction
143
- ? {
144
- event: eventType,
145
- payload,
146
- before,
147
- after,
148
- }
149
- : {
150
- event: 'scheduled',
151
- };
61
+ const input = {
62
+ flags,
63
+ projectId,
64
+ organizationId,
65
+ api,
66
+ apiHost: config.apiUrl,
67
+ token: config.token || undefined,
68
+ withUserToken,
69
+ resources: resolvedResources,
70
+ blueprintFileDir: dirname(blueprint.fileInfo.blueprintFilePath),
71
+ log,
72
+ };
73
+ const plan = resolveInvocationPlan(resource);
74
+ const context = buildContext(plan, input);
75
+ const invokeInput = await collectInvokeInput(plan, input, defaultInvocationDeps);
76
+ const invokeOptions = buildInvokeOptions(plan, invokeInput, eventType);
152
77
  // Records each nested context.invoke() so they can be surfaced in order after the spinner resolves.
153
78
  const nestedRuns = [];
154
79
  let nestedOrder = 0;
80
+ const stepEvents = [];
81
+ const onStepEvent = plan.isDurable
82
+ ? (event) => {
83
+ stepEvents.push(event);
84
+ }
85
+ : undefined;
155
86
  /**
156
87
  * Re-entrant callback that resolves a nested context.invoke(name, payload):
157
88
  * it finds the named function in the same blueprint, reuses the already
@@ -164,7 +95,7 @@ export async function functionTestCore(options) {
164
95
  const runNested = async (nestedName, nestedPayload, ctx, depth, callStack) => {
165
96
  const order = ++nestedOrder;
166
97
  const nestedResource = findFunctionInResources(blueprint.resources, nestedName);
167
- const result = await runFunction(nestedResource, { payload: { data: nestedPayload }, event: 'nested' }, { ...ctx, resources: resolvedResources }, { depth, callStack, onNestedInvoke: runNested });
98
+ const result = await runFunction(nestedResource, { payload: nestedPayload, event: 'nested' }, { ...ctx, resources: resolvedResources }, { depth, callStack, onNestedInvoke: runNested });
168
99
  nestedRuns.push({ order, name: nestedName, logs: result.logs, error: result.error });
169
100
  return result;
170
101
  };
@@ -183,11 +114,12 @@ export async function functionTestCore(options) {
183
114
  }
184
115
  };
185
116
  const spinner = log.ora('Executing function...').start();
186
- const { json: response, logs, error, } = await testAction(resource, invokeOptions, contextOptions, {
117
+ const { json: response, logs, error, } = await testAction(resource, invokeOptions, context, {
187
118
  timeout: timeout ? timeout : resource.timeout,
188
119
  onNestedInvoke: runNested,
189
120
  depth: 0,
190
121
  callStack: [fnName],
122
+ onStepEvent,
191
123
  });
192
124
  if (error) {
193
125
  spinner.fail('Function execution failed.');
@@ -199,6 +131,12 @@ export async function functionTestCore(options) {
199
131
  logNestedRuns();
200
132
  const errorMessage = error.stack || error.message || error.name;
201
133
  const isTimeout = error.message?.includes('Timeout');
134
+ if (plan.isDurable && stepEvents.length > 0) {
135
+ log('Steps:');
136
+ for (const line of renderStepTimeline(stepEvents)) {
137
+ log(line);
138
+ }
139
+ }
202
140
  return {
203
141
  success: false,
204
142
  error: errorMessage,
@@ -216,6 +154,12 @@ export async function functionTestCore(options) {
216
154
  log('Response:');
217
155
  log(JSON.stringify(response, null, 2));
218
156
  }
157
+ if (plan.isDurable && stepEvents.length > 0) {
158
+ log('Steps:');
159
+ for (const line of renderStepTimeline(stepEvents)) {
160
+ log(line);
161
+ }
162
+ }
219
163
  return { success: true, json: { response, logs } };
220
164
  }
221
165
  catch (error) {