@sanity/runtime-cli 17.8.0 → 17.9.0

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.
@@ -127,7 +127,7 @@ class PayloadPanel extends ApiBaseElement {
127
127
  // for certain other specific function types, set the payload to something specific, or reload whatever was loaded into its document payload before
128
128
  switch (selectedFunction.type) {
129
129
  case this.SANITY_FUNCTION_SYNC_TAG_INVALIDATE:
130
- this.#updateCodeView(this.api.store.payload, {data: {syncTags: ['s1:1234', 's2:5678']}})
130
+ this.#updateCodeView(this.api.store.payload, {syncTags: ['s1:1234', 's2:5678']})
131
131
  break
132
132
  default:
133
133
  this.#updateCodeView(this.api.store.payload, this.documentCacheByFunction[selectedIndex])
@@ -24,10 +24,13 @@ export declare function promptForOrganization({ token, knownOrganizationId, logg
24
24
  /**
25
25
  * Asks the user whether to scope a Blueprint to an organization or a project.
26
26
  */
27
- export declare function promptForScope({ allowBack, hasProjects, }?: {
27
+ export declare function promptForScope({ allowBack, hasProjects, message, }?: {
28
28
  allowBack?: boolean;
29
29
  hasProjects?: boolean;
30
+ message?: string;
30
31
  }): Promise<ScopeType | Back>;
32
+ /** Offers a recovery path after the selected scope cannot be read. */
33
+ export declare function promptForScopeRecovery(): Promise<'exit' | 'retry'>;
31
34
  /**
32
35
  * Prompts the user to pick a project from a pre-fetched list of projects.
33
36
  * Use after promptForOrganization when the user chose project scope.
@@ -121,7 +121,7 @@ export async function promptForOrganization({ token, knownOrganizationId, logger
121
121
  /**
122
122
  * Asks the user whether to scope a Blueprint to an organization or a project.
123
123
  */
124
- export async function promptForScope({ allowBack = false, hasProjects = true, } = {}) {
124
+ export async function promptForScope({ allowBack = false, hasProjects = true, message = 'How would you like to scope your Blueprint?', } = {}) {
125
125
  const choices = [
126
126
  {
127
127
  name: 'Organization',
@@ -136,12 +136,26 @@ export async function promptForScope({ allowBack = false, hasProjects = true, }
136
136
  },
137
137
  ];
138
138
  const answer = await select({
139
- message: 'How would you like to scope your Blueprint?',
139
+ message,
140
140
  choices: withBackChoice(choices, allowBack),
141
141
  default: 'organization',
142
142
  });
143
143
  return answer;
144
144
  }
145
+ /** Offers a recovery path after the selected scope cannot be read. */
146
+ export async function promptForScopeRecovery() {
147
+ return await select({
148
+ message: 'What would you like to do?',
149
+ choices: [
150
+ {
151
+ name: 'Choose another organization or project',
152
+ value: 'retry',
153
+ },
154
+ { name: 'Exit', value: 'exit' },
155
+ ],
156
+ default: 'retry',
157
+ });
158
+ }
145
159
  /**
146
160
  * Prompts the user to pick a project from a pre-fetched list of projects.
147
161
  * Use after promptForOrganization when the user chose project scope.
@@ -39,7 +39,7 @@ export async function runDurable(handler, {context, event, logger}, io) {
39
39
  }
40
40
 
41
41
  const step = {
42
- run: ({name, handler: fn}) => track(name, 'run', () => fn({logger, attempt: 1})),
42
+ run: ({name, handler: fn}) => track(name || 'step-run', 'run', () => fn({logger, attempt: 1})),
43
43
  }
44
44
 
45
45
  return handler({context, event, step, logger})
@@ -9,6 +9,7 @@ import { SANITY_FUNCTION_DURABLE } from '../../../constants.js';
9
9
  import { groupBlueprintResources } from '../../blueprints/group-resources.js';
10
10
  import { isGroqContextOptions, } from '../../types.js';
11
11
  import { acquireFunctionBuild } from './prepare.js';
12
+ import { isGroqInvocation } from './resolve-invoke-type.js';
12
13
  function getChildProcessWrapperPath() {
13
14
  return fileURLToPath(new URL('../child-process-wrapper.js', import.meta.url));
14
15
  }
@@ -17,13 +18,16 @@ export function sanitizeLogs(logs) {
17
18
  }
18
19
  export async function applyGroqRule(resource, payload, projectId, dataset) {
19
20
  const { before, after, payload: data = null } = payload;
21
+ const event = resource.event;
20
22
  // If there is no rule set return everything
21
- if (!resource.event)
23
+ if (!event)
22
24
  return data;
23
25
  // default groq rule is: gimme full doc content. otherwise, parse + eval custom rule
24
26
  // applying the GROQ filter may result in a slimmer set of documents
25
- if (resource.event.filter || resource.event.projection) {
26
- const query = `*[${resource.event.filter || ''}]${resource.event.projection || ''}`;
27
+ const filter = 'filter' in event ? event.filter : undefined;
28
+ const projection = 'projection' in event ? event.projection : undefined;
29
+ if (filter || projection) {
30
+ const query = `*[${filter || ''}]${projection || ''}`;
27
31
  try {
28
32
  const rule = groq.parse(query, { mode: 'delta' });
29
33
  const sanity = projectId && dataset ? { projectId, dataset } : undefined;
@@ -44,20 +48,23 @@ export default async function invoke(resource, payload, context, options) {
44
48
  throw new Error(`Function resource "${resource.name}" is missing the 'src' property.`);
45
49
  }
46
50
  const { forceColor = true, timeout = 10, onNestedInvoke, depth = 0, callStack = [], onStepEvent = () => { }, } = options;
51
+ // `payload.payload` is the event body for every function type: the document for
52
+ // GROQ-based events, whatever the producer sent for the rest. The handler always
53
+ // receives it as `event.data` (see the `payload` built in `start()` below).
54
+ let filteredData = payload?.payload || {};
47
55
  // Apply the GROQ rule before preparing a build so a filtered-out event skips
48
56
  // the (potentially expensive) transpile entirely.
49
- let filteredData = payload?.payload?.data || {};
50
- if (resource.type === 'sanity.function.document' ||
51
- resource.type === 'sanity.function.media-library.asset') {
57
+ if (isGroqInvocation(resource)) {
52
58
  if (!isGroqContextOptions(context)) {
53
59
  throw new Error('GROQ-based functions require a context with clientOptions');
54
60
  }
55
61
  const { projectId, dataset } = context.clientOptions;
56
62
  if ('before' in payload) {
57
63
  filteredData = await applyGroqRule(resource, payload, projectId, dataset);
64
+ const filter = resource.event && 'filter' in resource.event ? resource.event.filter : '';
58
65
  if (typeof filteredData === 'undefined') {
59
66
  return {
60
- logs: `⚠️ Filter "${resource.event?.filter}" returned an empty result. Skipping invoke.`,
67
+ logs: `⚠️ Filter "${filter}" returned an empty result. Skipping invoke.`,
61
68
  error: undefined,
62
69
  json: undefined,
63
70
  };
@@ -0,0 +1,51 @@
1
+ import type { BlueprintResource } from '@sanity/blueprints';
2
+ import type { Logger } from '../../logger.js';
3
+ import type { EventType, FunctionGroqResource, FunctionResource, FunctionTestFlags, InvokeContextOptions, InvokePayloadOptions } from '../../types.js';
4
+ import buildPayload from '../build-payload.js';
5
+ import { fetchAsset, fetchDocument } from '../fetch-document.js';
6
+ export type InvocationPlan = {
7
+ kind: 'groq';
8
+ inputSource: 'document' | 'media';
9
+ isDurable: boolean;
10
+ } | {
11
+ kind: 'plain';
12
+ isDurable: false;
13
+ } | {
14
+ kind: 'durable';
15
+ inputSource: 'payload';
16
+ isDurable: true;
17
+ };
18
+ export type InputContext = {
19
+ flags: FunctionTestFlags;
20
+ projectId?: string;
21
+ organizationId?: string;
22
+ api?: string;
23
+ apiHost: string;
24
+ token?: string;
25
+ withUserToken?: boolean;
26
+ resources: BlueprintResource[];
27
+ blueprintFileDir: string;
28
+ log: Logger;
29
+ };
30
+ type InvocationDeps = {
31
+ fetchDocument: typeof fetchDocument;
32
+ fetchAsset: typeof fetchAsset;
33
+ buildPayload: typeof buildPayload;
34
+ };
35
+ export declare const defaultInvocationDeps: InvocationDeps;
36
+ export interface InvokeInput {
37
+ payload: Record<string, unknown>;
38
+ before: Record<string, unknown> | null;
39
+ after: Record<string, unknown> | null;
40
+ }
41
+ export declare const resolveInvocationPlan: (resource: FunctionResource) => InvocationPlan;
42
+ /**
43
+ * True when the function's event is delivered through a GROQ rule (a document or
44
+ * media-library event, whether plain or durable), so its payload is a document the
45
+ * rule may narrow before the handler sees it.
46
+ */
47
+ export declare const isGroqInvocation: (resource: FunctionResource) => resource is FunctionGroqResource;
48
+ export declare const collectInvokeInput: (plan: InvocationPlan, input: InputContext, deps: InvocationDeps) => Promise<InvokeInput>;
49
+ export declare const buildContext: (plan: InvocationPlan, input: InputContext) => InvokeContextOptions;
50
+ export declare const buildInvokeOptions: (plan: InvocationPlan, invokeInput: InvokeInput, eventType: EventType) => InvokePayloadOptions;
51
+ export {};
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { cwd } from 'node:process';
4
+ import { SANITY_FUNCTION_DOCUMENT, SANITY_FUNCTION_DURABLE, SANITY_FUNCTION_MEDIA_LIBRARY_ASSET, SANITY_FUNCTION_PUBSUB, SANITY_FUNCTION_SCHEDULED, SANITY_FUNCTION_SYNC_TAG_INVALIDATE, } from '../../../constants.js';
5
+ import { parseJsonObject } from '../../parse-json-object.js';
6
+ import buildPayload from '../build-payload.js';
7
+ import { fetchAsset, fetchDocument } from '../fetch-document.js';
8
+ export const defaultInvocationDeps = { fetchDocument, fetchAsset, buildPayload };
9
+ const assertNever = (resource) => {
10
+ throw new Error(`No invocation plan for function type: ${resource.type}`);
11
+ };
12
+ const resolveDurableInvocationPlan = (event) => {
13
+ switch (event?.type) {
14
+ case 'document':
15
+ return { kind: 'groq', inputSource: 'document', isDurable: true };
16
+ case 'media-library':
17
+ return { kind: 'groq', inputSource: 'media', isDurable: true };
18
+ case 'sync-tag-invalidate':
19
+ case undefined:
20
+ return { kind: 'durable', inputSource: 'payload', isDurable: true };
21
+ }
22
+ };
23
+ export const resolveInvocationPlan = (resource) => {
24
+ switch (resource.type) {
25
+ case SANITY_FUNCTION_DOCUMENT:
26
+ return { kind: 'groq', inputSource: 'document', isDurable: false };
27
+ case SANITY_FUNCTION_MEDIA_LIBRARY_ASSET:
28
+ return { kind: 'groq', inputSource: 'media', isDurable: false };
29
+ case SANITY_FUNCTION_DURABLE:
30
+ return resolveDurableInvocationPlan(resource.event);
31
+ case SANITY_FUNCTION_SCHEDULED:
32
+ case SANITY_FUNCTION_PUBSUB:
33
+ case SANITY_FUNCTION_SYNC_TAG_INVALIDATE:
34
+ return { kind: 'plain', isDurable: false };
35
+ default:
36
+ return assertNever(resource);
37
+ }
38
+ };
39
+ /**
40
+ * True when the function's event is delivered through a GROQ rule (a document or
41
+ * media-library event, whether plain or durable), so its payload is a document the
42
+ * rule may narrow before the handler sees it.
43
+ */
44
+ export const isGroqInvocation = (resource) => resolveInvocationPlan(resource).kind === 'groq';
45
+ export const collectInvokeInput = async (plan, input, deps) => {
46
+ const source = plan.kind === 'groq' ? plan.inputSource : 'none';
47
+ return resolveInvokeInput(source, input, deps);
48
+ };
49
+ export const buildContext = (plan, input) => {
50
+ const withToken = input.withUserToken ? { token: input.token } : {};
51
+ if (plan.kind === 'plain') {
52
+ return {
53
+ clientOptions: { ...withToken },
54
+ resources: input.resources,
55
+ blueprintFileDir: input.blueprintFileDir,
56
+ };
57
+ }
58
+ const isMedia = plan.kind === 'groq' && plan.inputSource === 'media';
59
+ return {
60
+ clientOptions: {
61
+ apiVersion: input.api,
62
+ dataset: input.flags.dataset,
63
+ projectId: input.projectId,
64
+ organizationId: input.organizationId,
65
+ ...withToken,
66
+ },
67
+ eventResourceType: isMedia ? 'media-library' : 'dataset',
68
+ eventResourceId: (isMedia ? input.flags['media-library-id'] : `${input.projectId}.${input.flags.dataset}`) ||
69
+ '',
70
+ functionResourceType: 'project',
71
+ functionResourceId: input.projectId || '',
72
+ resources: input.resources,
73
+ blueprintFileDir: input.blueprintFileDir,
74
+ };
75
+ };
76
+ export const buildInvokeOptions = (plan, invokeInput, eventType) => {
77
+ switch (plan.kind) {
78
+ case 'groq':
79
+ return {
80
+ event: eventType,
81
+ payload: invokeInput.payload,
82
+ before: invokeInput.before,
83
+ after: invokeInput.after,
84
+ };
85
+ case 'plain':
86
+ // Cron functions have no payload, but pubsub and sync-tag-invalidate carry
87
+ // whatever the producer sent, so pass it through for all of them.
88
+ return { event: 'plain', payload: invokeInput.payload };
89
+ case 'durable':
90
+ return { event: 'durable', payload: invokeInput.payload };
91
+ }
92
+ };
93
+ const resolveInvokeInput = async (source, input, deps) => {
94
+ const { flags } = input;
95
+ const fetchById = (id) => source === 'document'
96
+ ? deps.fetchDocument(id, {
97
+ projectId: input.projectId,
98
+ dataset: flags.dataset,
99
+ apiVersion: input.api,
100
+ apiHost: input.apiHost,
101
+ token: input.token,
102
+ }, input.log)
103
+ : deps.fetchAsset(id, {
104
+ mediaLibraryId: flags['media-library-id'],
105
+ apiVersion: input.api,
106
+ apiHost: input.apiHost,
107
+ token: input.token,
108
+ }, input.log);
109
+ let before = null;
110
+ let after = null;
111
+ if (flags['data-before'] && flags['data-after']) {
112
+ before = parseJsonObject(flags['data-before']);
113
+ after = parseJsonObject(flags['data-after']);
114
+ }
115
+ else if (flags['file-before'] && flags['file-after']) {
116
+ before = parseJsonObject(readFileSync(join(cwd(), flags['file-before']), 'utf8'));
117
+ after = parseJsonObject(readFileSync(join(cwd(), flags['file-after']), 'utf8'));
118
+ }
119
+ else if (flags['document-id-before'] && flags['document-id-after']) {
120
+ before = await fetchById(flags['document-id-before']);
121
+ after = await fetchById(flags['document-id-after']);
122
+ }
123
+ let payload;
124
+ if (after)
125
+ payload = after;
126
+ else if (flags['document-id'])
127
+ payload = await fetchById(flags['document-id']);
128
+ else
129
+ payload = deps.buildPayload({ data: flags.data, file: flags.file });
130
+ return { payload, before, after };
131
+ };
@@ -14,7 +14,7 @@ export interface ActionResponse {
14
14
  error: string | null;
15
15
  }
16
16
  /** @internal */
17
- export type FunctionGroqResource = BlueprintDocumentFunctionResource | BlueprintMediaLibraryAssetFunctionResource;
17
+ export type FunctionGroqResource = BlueprintDocumentFunctionResource | BlueprintMediaLibraryAssetFunctionResource | BlueprintDurableFunctionResource;
18
18
  export type FunctionResource = FunctionGroqResource | BlueprintPubSubFunctionResource | BlueprintScheduledFunctionResource | BlueprintSyncTagInvalidateFunctionResource | BlueprintDurableFunctionResource;
19
19
  /** Function resources that declare an `event`; PubSub functions are triggered by other functions */
20
20
  export type EventFunctionResource = Exclude<FunctionResource, BlueprintPubSubFunctionResource>;
@@ -111,7 +111,7 @@ export interface InvokeNonGroqPayloadOptions {
111
111
  export type InvokePayloadOptions = InvokeGroqPayloadOptions | InvokeNonGroqPayloadOptions;
112
112
  export type InvokePayloadMetadata = Pick<InvokeGroqPayloadOptions, 'event' | 'before' | 'after'> | Pick<InvokeNonGroqPayloadOptions, 'event'>;
113
113
  export type EventType = 'create' | 'update' | 'delete';
114
- export type NonGroqEventType = 'scheduled' | 'nested' | 'durable';
114
+ export type NonGroqEventType = 'plain' | 'nested' | 'durable';
115
115
  export declare function isEventType(arg: string): arg is EventType;
116
116
  /** @internal */
117
117
  export interface InvokeGroqContextOptions {
@@ -315,4 +315,23 @@ export interface DurableStepEvent {
315
315
  error?: InvocationError;
316
316
  durationMs?: number;
317
317
  }
318
+ export interface FunctionTestFlags {
319
+ data?: string;
320
+ event?: string;
321
+ file?: string;
322
+ timeout?: number;
323
+ api?: string;
324
+ dataset?: string;
325
+ 'project-id'?: string;
326
+ 'organization-id'?: string;
327
+ 'document-id'?: string;
328
+ 'with-user-token'?: boolean;
329
+ 'data-before'?: string;
330
+ 'data-after'?: string;
331
+ 'file-before'?: string;
332
+ 'file-after'?: string;
333
+ 'document-id-before'?: string;
334
+ 'document-id-after'?: string;
335
+ 'media-library-id'?: string;
336
+ }
318
337
  export {};
@@ -1556,9 +1556,10 @@
1556
1556
  "blueprints:stacks": {
1557
1557
  "aliases": [],
1558
1558
  "args": {},
1559
- "description": "Shows all Stacks associated with a project or organization. By default, lists Stacks scoped to the local Blueprint.\n\nUse this to discover existing Stacks you can scope a local Blueprint to (using 'blueprints config --edit'), or to audit what's deployed across your project.\n\nUse --include-projects with --organization-id to also list Stacks from all projects within the organization.",
1559
+ "description": "Shows all Stacks associated with a project or organization. By default, lists Stacks scoped to the local Blueprint.\n\nUse this to discover existing Stacks you can scope a local Blueprint to (using 'blueprints config --edit'), or to audit what's deployed across your project.\n\nWithout a scope, prompts for an organization or project. Use --all to list Stacks across every organization and project you can access, or --include-projects with --organization-id for one organization and its projects.",
1560
1560
  "examples": [
1561
1561
  "<%= config.bin %> <%= command.id %>",
1562
+ "<%= config.bin %> <%= command.id %> --all",
1562
1563
  "<%= config.bin %> <%= command.id %> --project-id <projectId>",
1563
1564
  "<%= config.bin %> <%= command.id %> --organization-id <organizationId>",
1564
1565
  "<%= config.bin %> <%= command.id %> --organization-id <organizationId> --include-projects"
@@ -1620,7 +1621,8 @@
1620
1621
  "description": "Sanity project ID used to scope Blueprint and Stack",
1621
1622
  "exclusive": [
1622
1623
  "organization-id",
1623
- "include-projects"
1624
+ "include-projects",
1625
+ "all"
1624
1626
  ],
1625
1627
  "name": "project-id",
1626
1628
  "hasDynamicHelp": false,
@@ -1635,7 +1637,8 @@
1635
1637
  ],
1636
1638
  "description": "Sanity organization ID used to scope Blueprint and Stack",
1637
1639
  "exclusive": [
1638
- "project-id"
1640
+ "project-id",
1641
+ "all"
1639
1642
  ],
1640
1643
  "name": "organization-id",
1641
1644
  "hasDynamicHelp": false,
@@ -1650,6 +1653,17 @@
1650
1653
  "name": "include-projects",
1651
1654
  "allowNo": false,
1652
1655
  "type": "boolean"
1656
+ },
1657
+ "all": {
1658
+ "description": "List Stacks from every organization and project you have access to",
1659
+ "exclusive": [
1660
+ "project-id",
1661
+ "organization-id",
1662
+ "include-projects"
1663
+ ],
1664
+ "name": "all",
1665
+ "allowNo": false,
1666
+ "type": "boolean"
1653
1667
  }
1654
1668
  },
1655
1669
  "hasDynamicHelp": false,
@@ -1662,7 +1676,7 @@
1662
1676
  "summary": "List remote Stack deployments for your project or organization",
1663
1677
  "enableJsonFlag": true,
1664
1678
  "needs": [
1665
- "scope"
1679
+ "token"
1666
1680
  ],
1667
1681
  "isESM": true,
1668
1682
  "relativePath": [
@@ -3127,5 +3141,5 @@
3127
3141
  ]
3128
3142
  }
3129
3143
  },
3130
- "version": "17.8.0"
3144
+ "version": "17.9.0"
3131
3145
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sanity/runtime-cli",
3
3
  "description": "Sanity's Runtime CLI for Blueprints and Functions",
4
- "version": "17.8.0",
4
+ "version": "17.9.0",
5
5
  "author": "Sanity Runtime Team",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -17,7 +17,9 @@ npx @sanity/cli blueprints stacks --project-id PROJECT_ID --json
17
17
  npx @sanity/cli blueprints stacks --organization-id ORGANIZATION_ID --json
18
18
  ```
19
19
 
20
- The JSON result contains `stacks`, `scopeType`, and `scopeId`; each entry carries `resourceCount` rather than a full `resources` array. Zero results prints `No stacks found` and emits no JSON. Match both the Stack and its scope; names alone may be ambiguous.
20
+ Always pass a scope flag or `--all` when automating. With no scope to resolve the command prompts for an organization, then for a project if you pick project scope; without a terminal, or with `--json`, it fails with a missing-scope error instead.
21
+
22
+ The JSON result contains `stacks`, `scopeType`, and `scopeId`; each entry carries `resourceCount` rather than a full `resources` array. Zero results prints `No stacks found`, and under `--json` returns an empty `stacks` array. Match both the Stack and its scope; names alone may be ambiguous.
21
23
 
22
24
  To also include Stacks scoped to the organization's projects:
23
25
 
@@ -25,4 +27,14 @@ To also include Stacks scoped to the organization's projects:
25
27
  npx @sanity/cli blueprints stacks --organization-id ORGANIZATION_ID --include-projects --json
26
28
  ```
27
29
 
28
- `--include-projects` requires `--organization-id`, cannot be combined with `--project-id`, and only queries the organization's first 50 projects. This command does not change the local Blueprint target; use `blueprints config --edit` for that.
30
+ `--include-projects` requires `--organization-id`, cannot be combined with `--project-id`, and only queries the organization's first 50 projects.
31
+
32
+ To sweep every organization and project the token can reach:
33
+
34
+ ```sh
35
+ npx @sanity/cli blueprints stacks --all --json
36
+ ```
37
+
38
+ `--all` cannot be combined with the scope flags or `--include-projects`. It is best effort: scopes that return an error are skipped and reported as a count, and only the first 200 projects are queried. Its JSON result contains `stacks` and no top-level `scopeType`/`scopeId`; read each entry's own `scopeType` and `scopeId` instead.
39
+
40
+ This command does not change the local Blueprint target; use `blueprints config --edit` for that.