@sanity/runtime-cli 14.0.0 → 14.1.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.
package/README.md CHANGED
@@ -20,7 +20,7 @@ $ npm install -g @sanity/runtime-cli
20
20
  $ sanity-run COMMAND
21
21
  running command...
22
22
  $ sanity-run (--version)
23
- @sanity/runtime-cli/14.0.0 linux-x64 node-v24.13.0
23
+ @sanity/runtime-cli/14.1.0 linux-x64 node-v24.13.1
24
24
  $ sanity-run --help [COMMAND]
25
25
  USAGE
26
26
  $ sanity-run COMMAND
@@ -98,7 +98,7 @@ EXAMPLES
98
98
  $ sanity-run blueprints add function --name my-function --fn-type document-create --fn-type document-update --lang js
99
99
  ```
100
100
 
101
- _See code: [src/commands/blueprints/add.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/add.ts)_
101
+ _See code: [src/commands/blueprints/add.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/add.ts)_
102
102
 
103
103
  ## `sanity-run blueprints config`
104
104
 
@@ -133,7 +133,7 @@ EXAMPLES
133
133
  $ sanity-run blueprints config --edit --project-id <projectId> --stack <name-or-id>
134
134
  ```
135
135
 
136
- _See code: [src/commands/blueprints/config.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/config.ts)_
136
+ _See code: [src/commands/blueprints/config.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/config.ts)_
137
137
 
138
138
  ## `sanity-run blueprints deploy`
139
139
 
@@ -158,13 +158,15 @@ DESCRIPTION
158
158
 
159
159
  Use --no-wait to queue the deployment and return immediately without waiting for completion.
160
160
 
161
+ Set SANITY_ASSET_TIMEOUT (seconds) to override the 60-second timeout for processing resource assets.
162
+
161
163
  EXAMPLES
162
164
  $ sanity-run blueprints deploy
163
165
 
164
166
  $ sanity-run blueprints deploy --no-wait
165
167
  ```
166
168
 
167
- _See code: [src/commands/blueprints/deploy.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/deploy.ts)_
169
+ _See code: [src/commands/blueprints/deploy.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/deploy.ts)_
168
170
 
169
171
  ## `sanity-run blueprints destroy`
170
172
 
@@ -196,7 +198,7 @@ EXAMPLES
196
198
  $ sanity-run blueprints destroy --stack <name-or-id> --project-id <projectId> --force --no-wait
197
199
  ```
198
200
 
199
- _See code: [src/commands/blueprints/destroy.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/destroy.ts)_
201
+ _See code: [src/commands/blueprints/destroy.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/destroy.ts)_
200
202
 
201
203
  ## `sanity-run blueprints doctor`
202
204
 
@@ -210,7 +212,7 @@ FLAGS
210
212
  -p, --path=<value> [env: SANITY_BLUEPRINT_PATH] Path to a Blueprint file or directory containing one
211
213
  --fix Interactively fix configuration issues
212
214
  --json Format output as json.
213
- --verbose Verbose output
215
+ --[no-]verbose Verbose output; defaults to true
214
216
 
215
217
  DESCRIPTION
216
218
  Diagnose potential issues with local Blueprint and remote Stack configuration
@@ -222,7 +224,7 @@ DESCRIPTION
222
224
  issues.
223
225
  ```
224
226
 
225
- _See code: [src/commands/blueprints/doctor.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/doctor.ts)_
227
+ _See code: [src/commands/blueprints/doctor.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/doctor.ts)_
226
228
 
227
229
  ## `sanity-run blueprints info`
228
230
 
@@ -252,7 +254,7 @@ EXAMPLES
252
254
  $ sanity-run blueprints info --stack <name-or-id>
253
255
  ```
254
256
 
255
- _See code: [src/commands/blueprints/info.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/info.ts)_
257
+ _See code: [src/commands/blueprints/info.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/info.ts)_
256
258
 
257
259
  ## `sanity-run blueprints init [DIR]`
258
260
 
@@ -302,7 +304,7 @@ EXAMPLES
302
304
  $ sanity-run blueprints init --blueprint-type <json|js|ts> --stack-name <stackName>
303
305
  ```
304
306
 
305
- _See code: [src/commands/blueprints/init.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/init.ts)_
307
+ _See code: [src/commands/blueprints/init.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/init.ts)_
306
308
 
307
309
  ## `sanity-run blueprints logs`
308
310
 
@@ -331,7 +333,7 @@ EXAMPLES
331
333
  $ sanity-run blueprints logs --watch
332
334
  ```
333
335
 
334
- _See code: [src/commands/blueprints/logs.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/logs.ts)_
336
+ _See code: [src/commands/blueprints/logs.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/logs.ts)_
335
337
 
336
338
  ## `sanity-run blueprints plan`
337
339
 
@@ -357,7 +359,7 @@ EXAMPLES
357
359
  $ sanity-run blueprints plan
358
360
  ```
359
361
 
360
- _See code: [src/commands/blueprints/plan.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/plan.ts)_
362
+ _See code: [src/commands/blueprints/plan.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/plan.ts)_
361
363
 
362
364
  ## `sanity-run blueprints stacks`
363
365
 
@@ -386,7 +388,7 @@ EXAMPLES
386
388
  $ sanity-run blueprints stacks --organization-id <organizationId>
387
389
  ```
388
390
 
389
- _See code: [src/commands/blueprints/stacks.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/blueprints/stacks.ts)_
391
+ _See code: [src/commands/blueprints/stacks.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/blueprints/stacks.ts)_
390
392
 
391
393
  ## `sanity-run functions add`
392
394
 
@@ -435,7 +437,7 @@ EXAMPLES
435
437
  $ sanity-run functions add --name my-function --type document-create --type document-update --lang js
436
438
  ```
437
439
 
438
- _See code: [src/commands/functions/add.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/functions/add.ts)_
440
+ _See code: [src/commands/functions/add.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/functions/add.ts)_
439
441
 
440
442
  ## `sanity-run functions dev`
441
443
 
@@ -469,7 +471,7 @@ EXAMPLES
469
471
  $ sanity-run functions dev --timeout 60
470
472
  ```
471
473
 
472
- _See code: [src/commands/functions/dev.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/functions/dev.ts)_
474
+ _See code: [src/commands/functions/dev.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/functions/dev.ts)_
473
475
 
474
476
  ## `sanity-run functions env add NAME KEY VALUE`
475
477
 
@@ -496,7 +498,7 @@ EXAMPLES
496
498
  $ sanity-run functions env add MyFunction API_URL https://api.example.com/
497
499
  ```
498
500
 
499
- _See code: [src/commands/functions/env/add.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/functions/env/add.ts)_
501
+ _See code: [src/commands/functions/env/add.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/functions/env/add.ts)_
500
502
 
501
503
  ## `sanity-run functions env list NAME`
502
504
 
@@ -520,7 +522,7 @@ EXAMPLES
520
522
  $ sanity-run functions env list MyFunction
521
523
  ```
522
524
 
523
- _See code: [src/commands/functions/env/list.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/functions/env/list.ts)_
525
+ _See code: [src/commands/functions/env/list.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/functions/env/list.ts)_
524
526
 
525
527
  ## `sanity-run functions env remove NAME KEY`
526
528
 
@@ -546,7 +548,7 @@ EXAMPLES
546
548
  $ sanity-run functions env remove MyFunction API_URL
547
549
  ```
548
550
 
549
- _See code: [src/commands/functions/env/remove.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/functions/env/remove.ts)_
551
+ _See code: [src/commands/functions/env/remove.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/functions/env/remove.ts)_
550
552
 
551
553
  ## `sanity-run functions logs [NAME]`
552
554
 
@@ -586,7 +588,7 @@ EXAMPLES
586
588
  $ sanity-run functions logs <name> --delete
587
589
  ```
588
590
 
589
- _See code: [src/commands/functions/logs.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/functions/logs.ts)_
591
+ _See code: [src/commands/functions/logs.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/functions/logs.ts)_
590
592
 
591
593
  ## `sanity-run functions test [NAME]`
592
594
 
@@ -640,7 +642,7 @@ EXAMPLES
640
642
  $ sanity-run functions test <name> --event update --data-before '{ "title": "before" }' --data-after '{ "title": "after" }'
641
643
  ```
642
644
 
643
- _See code: [src/commands/functions/test.ts](https://github.com/sanity-io/runtime-cli/blob/v14.0.0/src/commands/functions/test.ts)_
645
+ _See code: [src/commands/functions/test.ts](https://github.com/sanity-io/runtime-cli/blob/v14.1.0/src/commands/functions/test.ts)_
644
646
 
645
647
  ## `sanity-run help [COMMAND]`
646
648
 
@@ -16,7 +16,7 @@ export interface LocatedBlueprintsConfig extends BlueprintsConfig {
16
16
  export declare function readConfigFile(blueprintFilePath?: string): LocatedBlueprintsConfig | null;
17
17
  /**
18
18
  * Create or update the config file to disk.
19
- * One of organizationId or projectId must be provided. Not both.
19
+ * One of organizationId or projectId must be provided.
20
20
  * Blueprint config version and updatedAt timestamp are set automatically.
21
21
  * @param options - the options to write the config file
22
22
  * @param options.blueprintFilePath - the path to the blueprint file
@@ -26,13 +26,9 @@ export declare function readConfigFile(blueprintFilePath?: string): LocatedBluep
26
26
  */
27
27
  export declare function writeConfigFile(blueprintFilePath: string, options: {
28
28
  stackId?: string;
29
- } & ({
30
- organizationId: string;
31
- projectId?: string;
32
- } | {
33
- projectId: string;
34
29
  organizationId?: string;
35
- })): BlueprintsConfig;
30
+ projectId?: string;
31
+ }): BlueprintsConfig;
36
32
  /**
37
33
  * Update the config file with the given properties.
38
34
  * Config file must already exist.
@@ -20,7 +20,7 @@ export function readConfigFile(blueprintFilePath) {
20
20
  }
21
21
  /**
22
22
  * Create or update the config file to disk.
23
- * One of organizationId or projectId must be provided. Not both.
23
+ * One of organizationId or projectId must be provided.
24
24
  * Blueprint config version and updatedAt timestamp are set automatically.
25
25
  * @param options - the options to write the config file
26
26
  * @param options.blueprintFilePath - the path to the blueprint file
@@ -7,6 +7,7 @@ export interface LogStreamingConfig {
7
7
  showBanner?: boolean;
8
8
  verbose?: boolean;
9
9
  log: ReturnType<typeof Logger>;
10
+ onActivity?: () => void;
10
11
  }
11
12
  export interface StreamLogsOptions {
12
13
  stackId: string;
@@ -79,6 +79,7 @@ export async function setupLogStreaming(config) {
79
79
  if (!isNewerLog(logEntry, newestTimestamp))
80
80
  return;
81
81
  newestTimestamp = new Date(logEntry.timestamp).getTime();
82
+ config.onActivity?.();
82
83
  log(formatLogEntry(logEntry, verbose, previousLog));
83
84
  previousLog = logEntry;
84
85
  };
@@ -1,3 +1,4 @@
1
+ import type { Blueprint } from '@sanity/blueprints-parser';
1
2
  import type { Logger } from '../../utils/logger.js';
2
3
  import type { AuthParams, ScopeType, Stack, StackMutation } from '../../utils/types.js';
3
4
  export declare const stacksUrl: string;
@@ -50,6 +51,56 @@ export declare function updateStack({ stackId, stackMutation, auth, logger, }: {
50
51
  auth: AuthParams;
51
52
  logger: ReturnType<typeof Logger>;
52
53
  }): Promise<UpdateStackResponse>;
54
+ interface PlanResourceSnapshot {
55
+ name: string;
56
+ type: string;
57
+ parameters: Record<string, unknown>;
58
+ }
59
+ export interface PlanActionDetails {
60
+ name: string;
61
+ type: string;
62
+ parameters?: Record<string, unknown>;
63
+ existingResource?: PlanResourceSnapshot & {
64
+ id: string;
65
+ externalId?: string;
66
+ };
67
+ updatedResource?: PlanResourceSnapshot;
68
+ }
69
+ export interface DeploymentPlanAction {
70
+ type: 'create' | 'update' | 'destroy' | 'skip' | 'attach' | 'detach';
71
+ resourceType: string;
72
+ details: PlanActionDetails | null;
73
+ }
74
+ export interface DeploymentPlan {
75
+ plan: DeploymentPlanAction[];
76
+ summary: {
77
+ createCount: number;
78
+ updateCount: number;
79
+ destroyCount: number;
80
+ skipCount: number;
81
+ attachCount: number;
82
+ detachCount: number;
83
+ };
84
+ }
85
+ type PlanStackResponse = {
86
+ ok: true;
87
+ error: null;
88
+ problems: null;
89
+ deploymentPlan: DeploymentPlan;
90
+ } | {
91
+ ok: false;
92
+ error: string;
93
+ problems: {
94
+ message: string;
95
+ }[] | null;
96
+ deploymentPlan: null;
97
+ };
98
+ export declare function planStack({ stackId, document, auth, logger, }: {
99
+ stackId: string;
100
+ document: Blueprint;
101
+ auth: AuthParams;
102
+ logger: ReturnType<typeof Logger>;
103
+ }): Promise<PlanStackResponse>;
53
104
  interface DestroyStackResponse {
54
105
  ok: boolean;
55
106
  error: string | null;
@@ -75,6 +75,24 @@ export async function updateStack({ stackId, stackMutation, auth, logger, }) {
75
75
  stack: data,
76
76
  };
77
77
  }
78
+ export async function planStack({ stackId, document, auth, logger, }) {
79
+ const fetchFn = createTracedFetch(logger);
80
+ const response = await fetchFn(`${stacksUrl}/${stackId}/plan`, {
81
+ method: 'POST',
82
+ headers: getHeaders(auth),
83
+ body: JSON.stringify({ document }),
84
+ });
85
+ const data = await response.json();
86
+ if (!response.ok) {
87
+ return {
88
+ ok: false,
89
+ error: data.message || 'Failed to retrieve deployment plan',
90
+ problems: response.status === 400 && Array.isArray(data.problems) ? data.problems : null,
91
+ deploymentPlan: null,
92
+ };
93
+ }
94
+ return { ok: true, error: null, problems: null, deploymentPlan: data };
95
+ }
78
96
  export async function resolveStackIdByNameOrId(value, auth, logger) {
79
97
  if (value.startsWith('ST-') && value.length === 13)
80
98
  return value;
@@ -8,7 +8,9 @@ export default class DeployCommand extends DeployedStackCommand {
8
8
 
9
9
  Before deploying, run 'blueprints plan' to preview changes. After deployment, use 'blueprints info' to verify Stack status or 'blueprints logs' to monitor activity.
10
10
 
11
- Use --no-wait to queue the deployment and return immediately without waiting for completion.`;
11
+ Use --no-wait to queue the deployment and return immediately without waiting for completion.
12
+
13
+ Set SANITY_ASSET_TIMEOUT (seconds) to override the 60-second timeout for processing resource assets.`;
12
14
  static examples = [
13
15
  '<%= config.bin %> <%= command.id %>',
14
16
  '<%= config.bin %> <%= command.id %> --no-wait',
@@ -17,7 +17,11 @@ Run this command when encountering errors with other Blueprint commands. Use --f
17
17
  default: false,
18
18
  }),
19
19
  json: unhide(baseFlags.json),
20
- verbose: unhide(baseFlags.verbose),
20
+ verbose: Flags.boolean({
21
+ description: 'Verbose output; defaults to true',
22
+ default: true,
23
+ allowNo: true,
24
+ }),
21
25
  };
22
26
  async run() {
23
27
  const { token } = config;
@@ -53,7 +53,16 @@ export async function blueprintConfigCore(options) {
53
53
  return { success: true };
54
54
  }
55
55
  catch {
56
- return { success: false, error: 'Unable to update configuration.' };
56
+ // fallback to writeConfigFile if patchConfigFile fails
57
+ // creates a new config file with the given properties
58
+ try {
59
+ const newConfig = writeConfigFile(blueprintFilePath, configUpdate);
60
+ printConfig({ configLabel: 'Updated', log, config: newConfig });
61
+ return { success: true };
62
+ }
63
+ catch {
64
+ return { success: false, error: 'Unable to update configuration.' };
65
+ }
57
66
  }
58
67
  }
59
68
  // prompt for values interactively
@@ -1,10 +1,14 @@
1
- import { setTimeout } from 'node:timers/promises';
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
2
  import { stashAsset } from '../../actions/blueprints/assets.js';
3
3
  import { setupLogStreaming } from '../../actions/blueprints/logs-streaming.js';
4
4
  import { getStack, updateStack } from '../../actions/blueprints/stacks.js';
5
5
  import { niceId } from '../../utils/display/presenters.js';
6
6
  import { styleText } from '../../utils/style-text.js';
7
7
  import { isLocalFunctionCollection, isLocalFunctionResource } from '../../utils/types.js';
8
+ const DEFAULT_ASSET_TIMEOUT = 60;
9
+ const assetTimeoutS = Number(process.env.SANITY_ASSET_TIMEOUT) || DEFAULT_ASSET_TIMEOUT;
10
+ const assetTimeoutMs = assetTimeoutS * 1000;
11
+ const warnTimeoutMs = assetTimeoutMs / 2;
8
12
  export async function blueprintDeployCore(options) {
9
13
  const { bin = 'sanity', log, auth, stackId, scopeType, scopeId, deployedStack, blueprint, flags, } = options;
10
14
  const { verbose } = flags;
@@ -33,7 +37,26 @@ export async function blueprintDeployCore(options) {
33
37
  log('Processing function assets...');
34
38
  for (const resource of allFunctionResources) {
35
39
  const fnSpinner = log.ora({ text: `Processing ${resource.name}...`, prefixText: ' ' }).start();
36
- const result = await stashAsset({ resource, auth, logger: log });
40
+ const warnTimer = setTimeout(() => {
41
+ fnSpinner.text = `Still processing ${resource.name}, this can take a moment...`;
42
+ }, warnTimeoutMs);
43
+ let result;
44
+ try {
45
+ result = await Promise.race([
46
+ stashAsset({ resource, auth, logger: log }),
47
+ sleep(assetTimeoutMs).then(() => {
48
+ throw new Error(`Processing ${resource.name} timed out after ${assetTimeoutS}s`);
49
+ }),
50
+ ]);
51
+ }
52
+ catch (err) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ fnSpinner.fail(msg);
55
+ return { success: false, error: msg };
56
+ }
57
+ finally {
58
+ clearTimeout(warnTimer);
59
+ }
37
60
  if (result.success && result.assetId) {
38
61
  resource.src = result.assetId;
39
62
  if (isLocalFunctionCollection(resource)) {
@@ -102,12 +125,17 @@ export async function blueprintDeployCore(options) {
102
125
  log(styleText('dim', 'Stack deployment progress:'));
103
126
  let logStreamCleanup = null;
104
127
  try {
128
+ let lastLogAt = Date.now();
129
+ let idleMessageShown = false;
105
130
  logStreamCleanup = await setupLogStreaming({
106
131
  stackId: stack.id,
107
132
  after: isoNow,
108
133
  auth,
109
134
  log,
110
135
  verbose,
136
+ onActivity: () => {
137
+ lastLogAt = Date.now();
138
+ },
111
139
  });
112
140
  while (true) {
113
141
  const { ok, stack: currentStack } = await getStack({ stackId: stack.id, auth, logger: log });
@@ -133,7 +161,12 @@ export async function blueprintDeployCore(options) {
133
161
  log(styleText(['bold', 'green'], 'Stack deployment completed!'));
134
162
  return { success: true, data: { resources } };
135
163
  }
136
- await setTimeout(1500);
164
+ if (!idleMessageShown && Date.now() - lastLogAt > 60_000) {
165
+ log(`No new activity for 60 seconds. The deployment is still running on Sanity servers.`);
166
+ log(`You can safely exit and check status later with \`${bin} blueprints info\`.`);
167
+ idleMessageShown = true;
168
+ }
169
+ await sleep(1500);
137
170
  }
138
171
  }
139
172
  catch (error) {
@@ -1,11 +1,11 @@
1
- import { setTimeout } from 'node:timers/promises';
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
2
  import { confirm } from '@inquirer/prompts';
3
3
  import { setupLogStreaming } from '../../actions/blueprints/logs-streaming.js';
4
4
  import { destroyStack, getStack, resolveStackIdByNameOrId } from '../../actions/blueprints/stacks.js';
5
5
  import { niceId } from '../../utils/display/presenters.js';
6
6
  import { styleText } from '../../utils/style-text.js';
7
7
  export async function blueprintDestroyCore(options) {
8
- const { log, token, blueprint, flags } = options;
8
+ const { bin = 'sanity', log, token, blueprint, flags } = options;
9
9
  const { force = false, 'project-id': flagProjectId, 'organization-id': flagOrganizationId, stack: flagStack, 'no-wait': noWait = false, verbose: _verbose = false, } = flags;
10
10
  // 3-flag combo: just destroy it
11
11
  if ((flagProjectId || flagOrganizationId) && flagStack && force) {
@@ -75,10 +75,10 @@ export async function blueprintDestroyCore(options) {
75
75
  let i = 5;
76
76
  while (i >= 0) {
77
77
  destroySpinner.text = `Destroying Stack deployment in ${styleText('bold', (i--).toString())} seconds...`;
78
- await setTimeout(1000);
78
+ await sleep(1000);
79
79
  }
80
80
  destroySpinner.text = 'Destroying Stack deployment 💥';
81
- await setTimeout(500);
81
+ await sleep(500);
82
82
  }
83
83
  else {
84
84
  destroySpinner.start();
@@ -97,11 +97,16 @@ export async function blueprintDestroyCore(options) {
97
97
  log(styleText('dim', 'Stack destruction progress:'));
98
98
  let logStreamCleanup = null;
99
99
  try {
100
+ let lastLogAt = Date.now();
101
+ let idleMessageShown = false;
100
102
  logStreamCleanup = await setupLogStreaming({
101
103
  stackId: stack.id,
102
104
  after: isoNow,
103
105
  auth,
104
106
  log,
107
+ onActivity: () => {
108
+ lastLogAt = Date.now();
109
+ },
105
110
  });
106
111
  while (true) {
107
112
  const { ok, stack: currentStack } = await getStack({ stackId: stack.id, auth, logger: log });
@@ -119,7 +124,12 @@ export async function blueprintDestroyCore(options) {
119
124
  logStreamCleanup();
120
125
  return { success: false, error: 'Stack destruction failed' };
121
126
  }
122
- await setTimeout(1500);
127
+ if (!idleMessageShown && Date.now() - lastLogAt > 60_000) {
128
+ log(`No new activity for 60 seconds. The destruction is still running on Sanity servers.`);
129
+ log(`You can safely exit and check status later with \`${bin} blueprints info\`.`);
130
+ idleMessageShown = true;
131
+ }
132
+ await sleep(1500);
123
133
  }
124
134
  }
125
135
  catch (error) {
@@ -1,202 +1,295 @@
1
- import { cwd } from 'node:process';
1
+ import { readFileSync } from 'node:fs';
2
+ import { arch, cwd, version as nodeVersion, platform } from 'node:process';
3
+ import * as resolve from 'empathic/resolve';
4
+ import ora from 'ora';
2
5
  import { readLocalBlueprint, } from '../../actions/blueprints/blueprint.js';
3
6
  import { getStack } from '../../actions/blueprints/stacks.js';
4
- import config from '../../config.js';
5
- import { capitalize, check, filePathRelativeToCwd, indent, niceId, severe, unsure, } from '../../utils/display/presenters.js';
7
+ import config, { RUNTIME_CLI_VERSION } from '../../config.js';
8
+ import { check, filePathRelativeToCwd, niceId, severe, unsure, } from '../../utils/display/presenters.js';
6
9
  import { styleText } from '../../utils/style-text.js';
7
10
  import { createTracedFetch } from '../../utils/traced-fetch.js';
8
11
  import { validTokenOrErrorMessage } from '../../utils/validated-token.js';
9
12
  import { blueprintConfigCore } from './config.js';
10
13
  const diagLookup = {
11
- online: 'Online',
14
+ online: 'Host online',
12
15
  tokenValid: 'Authenticated',
13
16
  blueprintValid: 'Blueprint valid',
14
17
  stackReady: 'Stack ready',
15
18
  userHasAccess: 'User has access',
16
19
  };
20
+ function sourceLabel(source) {
21
+ switch (source) {
22
+ case 'env':
23
+ return 'environment';
24
+ case 'module':
25
+ return 'blueprint module';
26
+ case 'config':
27
+ return 'config file';
28
+ case 'inferred':
29
+ return 'inferred';
30
+ default:
31
+ return 'unknown';
32
+ }
33
+ }
34
+ function renderSection(emit, title, rows) {
35
+ const pad = Math.max(...rows.map(([l]) => l.length)) + 3;
36
+ emit(styleText('bold', title));
37
+ for (const [label, value] of rows) {
38
+ emit(` ${styleText('dim', label.padEnd(pad))}${value}`);
39
+ }
40
+ }
17
41
  export async function blueprintDoctorCore(options) {
18
42
  const { bin, log, token, validateResources, flags: { verbose: v, path: p, fix }, } = options;
19
- const yikes = (s) => {
20
- log.error(styleText(['bgRedBright', 'whiteBright', 'bold'], ` ${s} `));
21
- };
22
- const here = cwd();
23
- const path = p || here;
43
+ const path = p || cwd();
24
44
  let tokenOrError;
25
- log.verbose(`Checking ${filePathRelativeToCwd(path)}`);
26
- // 3 states: null == unknown, true == good, false == bad
27
45
  const diagnostics = {};
28
46
  for (const key in diagLookup) {
29
- diagnostics[key] = null;
47
+ diagnostics[key] = { status: null };
30
48
  }
31
- // ONLINE
49
+ const envRows = [['Directory', p ? filePathRelativeToCwd(path) : path]];
50
+ const configRows = [];
51
+ const stackRows = [];
52
+ const spinner = ora('Running diagnostics...').start();
53
+ // --- ONLINE ---
32
54
  const fetchFn = createTracedFetch(log);
33
55
  try {
34
56
  const res = await fetchFn(config.apiUrl);
35
57
  if (res.ok) {
36
- log.verbose(`Successfully pinged ${config.apiUrl}`);
37
- diagnostics.online = res.ok;
58
+ diagnostics.online = { status: true };
38
59
  }
39
60
  else {
40
- yikes(`Failed to ping ${config.apiUrl}: ${res.status} ${res.statusText}`);
41
- diagnostics.online = false;
61
+ diagnostics.online = { status: false, detail: `${res.status} ${res.statusText}` };
42
62
  }
43
63
  }
44
- catch {
45
- yikes(`Failed to ping ${config.apiUrl}`);
64
+ catch (err) {
65
+ const reason = err instanceof Error ? err.message : 'unknown error';
66
+ diagnostics.online = { status: false, detail: reason };
46
67
  }
47
- // TOKEN
68
+ // --- TOKEN ---
48
69
  if (token) {
49
70
  tokenOrError = await validTokenOrErrorMessage(log, token);
50
71
  if (tokenOrError.ok) {
51
- diagnostics.tokenValid = true;
72
+ diagnostics.tokenValid = { status: true };
52
73
  }
53
74
  else {
54
- yikes(`Token error: ${tokenOrError.error.message}`);
55
- diagnostics.tokenValid = false;
75
+ diagnostics.tokenValid = { status: false, detail: tokenOrError.error.message };
56
76
  }
57
77
  }
58
78
  else {
59
- yikes('No authentication token found');
60
- diagnostics.tokenValid = false;
79
+ diagnostics.tokenValid = { status: false, detail: 'no token found' };
61
80
  }
62
- // BLUEPRINT file
81
+ // --- BLUEPRINT ---
63
82
  let localBlueprint;
64
83
  try {
65
84
  localBlueprint = await readLocalBlueprint(log, { resources: options.validateResources || false }, path);
66
- log.verbose(`Found blueprint file at ${filePathRelativeToCwd(localBlueprint.fileInfo.blueprintFilePath)}`);
85
+ envRows.push(['Blueprint', filePathRelativeToCwd(localBlueprint.fileInfo.blueprintFilePath)]);
67
86
  if (localBlueprint.errors.length === 0) {
68
- log.verbose(`Blueprint has no errors`);
69
- diagnostics.blueprintValid = true;
87
+ diagnostics.blueprintValid = { status: true };
70
88
  }
71
89
  else {
72
- log.verbose(`Blueprint errors: \n${localBlueprint.errors.join('\n ')}`);
73
- diagnostics.blueprintValid = false;
90
+ diagnostics.blueprintValid = {
91
+ status: false,
92
+ detail: `${localBlueprint.errors.length} error(s)`,
93
+ };
74
94
  }
75
95
  }
76
96
  catch {
77
- yikes(`Unable to read blueprint`);
78
- diagnostics.blueprintValid = false;
97
+ diagnostics.blueprintValid = { status: false, detail: 'unable to read file' };
79
98
  }
99
+ envRows.push(['API URL', config.apiUrl]);
100
+ envRows.push(['Runtime', `Node.js ${nodeVersion} (${platform}-${arch})`]);
101
+ if (RUNTIME_CLI_VERSION) {
102
+ envRows.push(['Internals', `v${RUNTIME_CLI_VERSION}`]);
103
+ }
104
+ const sanityCliPkgPath = resolve.from(path, '@sanity/cli/package.json', true);
105
+ if (sanityCliPkgPath) {
106
+ try {
107
+ const sanityCliPkg = JSON.parse(readFileSync(sanityCliPkgPath, 'utf8'));
108
+ envRows.push(['Sanity CLI', sanityCliPkg.version]);
109
+ }
110
+ catch { }
111
+ }
112
+ // --- CONFIGURATION ---
80
113
  if (localBlueprint) {
81
- const { scopeType, scopeId, stackId, sources } = localBlueprint;
82
- const sourceLabel = (source) => {
83
- switch (source) {
84
- case 'env':
85
- return 'environment';
86
- case 'module':
87
- return 'blueprint module';
88
- case 'config':
89
- return 'config file';
90
- case 'inferred':
91
- return 'inferred';
92
- default:
93
- return 'unknown';
94
- }
95
- };
114
+ const { scopeType, scopeId, stackId, sources, blueprintConfig } = localBlueprint;
115
+ if (blueprintConfig && 'configPath' in blueprintConfig && blueprintConfig.configPath) {
116
+ configRows.push(['Source', filePathRelativeToCwd(blueprintConfig.configPath)]);
117
+ }
118
+ else if (!blueprintConfig) {
119
+ configRows.push(['Source', styleText('dim', 'no config file')]);
120
+ }
121
+ if (blueprintConfig?.updatedAt) {
122
+ configRows.push(['Updated', new Date(blueprintConfig.updatedAt).toLocaleString('sv-SE')]);
123
+ }
96
124
  if (scopeType && scopeId) {
97
125
  const scopeSourceKey = scopeType === 'project' ? 'projectId' : 'organizationId';
98
- log.verbose(indent(`${capitalize(scopeType)}: ${niceId(scopeId)} (from ${sourceLabel(sources?.[scopeSourceKey])})`));
126
+ const label = scopeType === 'project' ? 'Project' : 'Organization';
127
+ configRows.push([
128
+ label,
129
+ `${niceId(scopeId)} ${styleText('dim', sourceLabel(sources?.[scopeSourceKey]))}`,
130
+ ]);
99
131
  }
100
132
  if (stackId) {
101
- log.verbose(indent(`Deployment: ${niceId(stackId)} (from ${sourceLabel(sources?.stackId)})`));
133
+ configRows.push([
134
+ 'Stack',
135
+ `${niceId(stackId)} ${styleText('dim', sourceLabel(sources?.stackId))}`,
136
+ ]);
102
137
  }
103
- // STACK + ACCESS
104
- if (diagnostics.online && diagnostics.tokenValid && token && stackId && scopeType && scopeId) {
138
+ // --- STACK + ACCESS ---
139
+ if (diagnostics.online.status &&
140
+ diagnostics.tokenValid.status &&
141
+ token &&
142
+ stackId &&
143
+ scopeType &&
144
+ scopeId) {
105
145
  const stackResponse = await getStack({
106
146
  auth: { token, scopeType, scopeId },
107
147
  stackId,
108
148
  logger: log,
109
149
  });
110
150
  if (stackResponse.ok) {
111
- log.verbose(`Deployment "Stack" ${niceId(stackId)} ready`);
112
- diagnostics.stackReady = true;
113
- diagnostics.userHasAccess = true;
151
+ diagnostics.stackReady = { status: true };
152
+ diagnostics.userHasAccess = { status: true };
153
+ const stack = stackResponse.stack;
154
+ if (stack) {
155
+ const label = stack.name ? `"${stack.name}" ${niceId(stackId)}` : niceId(stackId);
156
+ stackRows.push(['Stack', label]);
157
+ if (stack.recentOperation) {
158
+ const op = stack.recentOperation;
159
+ const time = op.completedAt || op.createdAt;
160
+ const timestamp = time
161
+ ? ` ${styleText('dim', new Date(time).toLocaleString('sv-SE'))}`
162
+ : '';
163
+ stackRows.push(['Operation', `${op.status}${timestamp}`]);
164
+ }
165
+ }
114
166
  }
115
167
  else if (stackResponse.response?.status === 404) {
116
- yikes(`Deployment "Stack" <${stackId}> not found`);
117
- diagnostics.stackReady = false;
168
+ diagnostics.stackReady = {
169
+ status: false,
170
+ detail: `Stack ${niceId(stackId)} not found (404)`,
171
+ };
118
172
  }
119
173
  else if (stackResponse.response?.status === 403 || stackResponse.response?.status === 401) {
120
- yikes(`User does not have access to "Stack" <${stackId}>`);
121
- diagnostics.userHasAccess = false;
174
+ diagnostics.userHasAccess = {
175
+ status: false,
176
+ detail: `no access to Stack ${niceId(stackId)} (${stackResponse.response.status})`,
177
+ };
122
178
  }
123
179
  else {
124
- yikes(`Unknown error with "Stack" <${stackId}>: ${stackResponse.error}`);
180
+ const statusSuffix = stackResponse.response?.status
181
+ ? ` (${stackResponse.response.status})`
182
+ : '';
183
+ diagnostics.stackReady = {
184
+ status: null,
185
+ detail: (stackResponse.error || 'unknown error') + statusSuffix,
186
+ };
187
+ diagnostics.userHasAccess = { status: null, detail: `unknown error${statusSuffix}` };
125
188
  }
126
189
  }
190
+ else if (stackId && scopeType && scopeId) {
191
+ diagnostics.stackReady = { status: null, detail: 'requires online + authenticated' };
192
+ diagnostics.userHasAccess = { status: null, detail: 'requires online + authenticated' };
193
+ }
194
+ else if (!stackId && !scopeType && !scopeId) {
195
+ diagnostics.stackReady = { status: null, detail: 'missing configuration' };
196
+ diagnostics.userHasAccess = { status: null, detail: 'missing configuration' };
197
+ }
127
198
  else {
199
+ const missing = [];
128
200
  if (!stackId)
129
- yikes(`Blueprints configuration is missing a Stack ID`);
130
- if (scopeType === 'project') {
131
- if (!scopeId)
132
- yikes(`Blueprints configuration is missing a Project ID`);
133
- }
134
- else if (scopeType === 'organization') {
135
- if (!scopeId)
136
- yikes(`Blueprints configuration is missing an Organization ID`);
137
- }
138
- else {
139
- if (!scopeType)
140
- yikes(`Blueprints configuration is missing a Scope Type`);
141
- if (!scopeId)
142
- yikes(`Blueprints configuration is missing a Scope ID`);
201
+ missing.push('Stack ID');
202
+ if (!scopeType)
203
+ missing.push('Scope Type');
204
+ if (!scopeId) {
205
+ const scopeLabel = scopeType === 'project'
206
+ ? 'Project ID'
207
+ : scopeType === 'organization'
208
+ ? 'Organization ID'
209
+ : 'Scope ID';
210
+ missing.push(scopeLabel);
143
211
  }
212
+ const detail = `missing ${missing.join(', ')}`;
213
+ diagnostics.stackReady = { status: null, detail };
214
+ diagnostics.userHasAccess = { status: null, detail };
144
215
  }
145
216
  }
146
- log('');
217
+ // --- RENDER REPORT ---
218
+ spinner.stop();
219
+ // Environment (verbose)
220
+ renderSection((msg) => log.verbose(msg), 'Environment', envRows);
221
+ log.verbose('');
222
+ // Configuration (verbose)
223
+ if (configRows.length > 0) {
224
+ renderSection((msg) => log.verbose(msg), 'Configuration', configRows);
225
+ log.verbose('');
226
+ }
227
+ // Deployment (verbose)
228
+ if (stackRows.length > 0) {
229
+ renderSection((msg) => log.verbose(msg), 'Deployment', stackRows);
230
+ log.verbose('');
231
+ }
232
+ // Checks (always visible)
233
+ const maxLabel = Math.max(...Object.values(diagLookup).map((l) => l.length));
234
+ log(styleText('bold', 'Checks'));
147
235
  let allGood = true;
148
- for (const [key, value] of Object.entries(diagnostics)) {
149
- switch (value) {
236
+ for (const [key, entry] of Object.entries(diagnostics)) {
237
+ const label = diagLookup[key].padEnd(maxLabel);
238
+ const detail = entry.detail ? ` ${styleText('dim', entry.detail)}` : '';
239
+ switch (entry.status) {
150
240
  case true:
151
- log(check(diagLookup[key]));
241
+ log(` ${check(label)}${detail}`);
152
242
  break;
153
243
  case false:
154
244
  allGood = false;
155
- log(severe(diagLookup[key]));
245
+ log(` ${severe(label)}${detail}`);
156
246
  break;
157
247
  case null:
158
248
  allGood = false;
159
- log(unsure(diagLookup[key]));
249
+ log(` ${unsure(label)}${detail}`);
160
250
  break;
161
- default:
162
- allGood = false;
163
- log(severe(`${key} is ${value}`));
164
251
  }
165
252
  }
253
+ // Result
254
+ const flatDiagnostics = {};
255
+ for (const [key, entry] of Object.entries(diagnostics)) {
256
+ flatDiagnostics[key] = entry.status;
257
+ }
258
+ log('');
166
259
  const errorMessage = 'One or more checks failed';
167
260
  if (allGood) {
168
261
  log(styleText(['bold', 'green'], 'All checks passed'));
169
262
  if (fix)
170
263
  log(styleText(['bold', 'yellow'], 'Nothing to fix; --fix flag is ignored'));
171
- return { success: true, data: { diagnostics } };
264
+ return { success: true, data: { diagnostics: flatDiagnostics } };
172
265
  }
173
- else if (fix) {
266
+ if (fix) {
174
267
  if (p) {
175
268
  return {
176
269
  success: false,
177
270
  error: `${errorMessage}. --fix cannot be used with --path`,
178
- data: { diagnostics },
271
+ data: { diagnostics: flatDiagnostics },
179
272
  };
180
273
  }
181
274
  if (!tokenOrError) {
182
275
  return {
183
276
  success: false,
184
277
  error: `${errorMessage}. Unable to fix: Missing authentication token`,
185
- data: { diagnostics },
278
+ data: { diagnostics: flatDiagnostics },
186
279
  };
187
280
  }
188
281
  if (tokenOrError?.ok === false) {
189
282
  return {
190
283
  success: false,
191
284
  error: `${errorMessage}. Unable to fix: ${tokenOrError.error.message}`,
192
- data: { diagnostics },
285
+ data: { diagnostics: flatDiagnostics },
193
286
  };
194
287
  }
195
288
  if (!localBlueprint) {
196
289
  return {
197
290
  success: false,
198
291
  error: `${errorMessage}. Unable to fix: Blueprint is missing or invalid`,
199
- data: { diagnostics },
292
+ data: { diagnostics: flatDiagnostics },
200
293
  };
201
294
  }
202
295
  return blueprintConfigCore({
@@ -208,5 +301,6 @@ export async function blueprintDoctorCore(options) {
208
301
  flags: { edit: true, verbose: v },
209
302
  });
210
303
  }
211
- return { success: false, error: errorMessage, data: { diagnostics } };
304
+ log(styleText('dim', ` Run \`${bin} blueprints doctor --fix\` to resolve configuration issues.`));
305
+ return { success: false, error: errorMessage, data: { diagnostics: flatDiagnostics } };
212
306
  }
@@ -1,32 +1,67 @@
1
- import { getStack, resolveStackIdByNameOrId } from '../../actions/blueprints/stacks.js';
2
- import { formatResourceTree, stackDeployDiff } from '../../utils/display/blueprints-formatting.js';
1
+ import { planStack, resolveStackIdByNameOrId } from '../../actions/blueprints/stacks.js';
2
+ import { formatDeploymentPlan, formatResourceTree, hasActionableChanges, } from '../../utils/display/blueprints-formatting.js';
3
3
  import { styleText } from '../../utils/style-text.js';
4
4
  export async function blueprintPlanCore(options) {
5
5
  const { bin = 'sanity', log, blueprint, token, flags } = options;
6
6
  const { verbose: _verbose = false } = flags;
7
7
  const { scopeType, scopeId, stackId: blueprintStackId, parsedBlueprint, fileInfo } = blueprint;
8
- log(`${styleText(['bold', 'blueBright'], 'Blueprint Stack deployment plan')} ${styleText('dim', `(${fileInfo.fileName})`)}`);
8
+ log(`${styleText(['bold', 'blueBright'], 'Local Blueprint')} ${styleText('dim', `(${fileInfo.fileName})`)}`);
9
9
  log(formatResourceTree(parsedBlueprint.resources));
10
- if (token && scopeType && scopeId) {
11
- const stackId = flags.stack
12
- ? await resolveStackIdByNameOrId(flags.stack, { token, scopeType, scopeId }, log)
13
- : blueprintStackId;
14
- if (stackId) {
15
- const stackResponse = await getStack({
16
- auth: { token, scopeType, scopeId },
17
- stackId,
18
- logger: log,
19
- });
20
- if (!stackResponse.ok) {
21
- log(styleText('dim', 'Unable to retrieve live Stack deployment for comparison'));
22
- }
23
- else {
24
- const diff = stackDeployDiff(parsedBlueprint, stackResponse.stack);
25
- if (diff)
26
- log(diff);
10
+ if (!token || !scopeType || !scopeId) {
11
+ log(styleText('dim', 'Unable to retrieve live Stack deployment for comparison'));
12
+ const errorMessage = !token
13
+ ? `Missing authentication token. Run \`${bin} login\` to authenticate.`
14
+ : `Missing Blueprint configuration. Run \`${bin} blueprints doctor --fix\` to repair.`;
15
+ return {
16
+ success: false,
17
+ error: errorMessage,
18
+ };
19
+ }
20
+ const stackId = flags.stack
21
+ ? await resolveStackIdByNameOrId(flags.stack, { token, scopeType, scopeId }, log)
22
+ : blueprintStackId;
23
+ if (!stackId) {
24
+ log(styleText('dim', 'Unable to retrieve live Stack deployment for comparison'));
25
+ return {
26
+ success: false,
27
+ error: `Missing Stack deployment configuration. Run \`${bin} blueprints doctor --fix\` to repair.`,
28
+ };
29
+ }
30
+ const auth = { token, scopeType, scopeId };
31
+ const spinner = log.ora('Generating deployment plan...').start();
32
+ const planResponse = await planStack({
33
+ stackId,
34
+ document: parsedBlueprint,
35
+ auth,
36
+ logger: log,
37
+ });
38
+ spinner.stop().clear();
39
+ if (!planResponse.ok) {
40
+ if (planResponse.problems) {
41
+ log('');
42
+ for (const problem of planResponse.problems) {
43
+ const messages = problem.message
44
+ .split('\n')
45
+ .map((s) => s.trim())
46
+ .filter(Boolean);
47
+ for (const msg of messages) {
48
+ log(` ${styleText(['bold', 'red'], '✘')} ${msg}`);
49
+ }
27
50
  }
51
+ log(`\n Fix the issues above before running "${styleText(['bold', 'magenta'], `${bin} blueprints deploy`)}"`);
28
52
  }
53
+ else {
54
+ log(styleText('dim', '\nUnable to retrieve deployment plan from server'));
55
+ }
56
+ return { success: false, error: 'Deployment plan has problems' };
57
+ }
58
+ log('');
59
+ log(formatDeploymentPlan(planResponse.deploymentPlan));
60
+ if (hasActionableChanges(planResponse.deploymentPlan)) {
61
+ log(`\n Run "${styleText(['bold', 'magenta'], `${bin} blueprints deploy`)}" to apply these Stack changes`);
62
+ }
63
+ else {
64
+ log(`\n ${styleText('dim', `No significant changes to deploy. Run \`${bin} blueprints deploy\` to apply.`)}`);
29
65
  }
30
- log(`\n Run "${styleText(['bold', 'magenta'], `${bin} blueprints deploy`)}" to deploy these Stack changes`);
31
66
  return { success: true };
32
67
  }
@@ -1,8 +1,10 @@
1
1
  import type { Blueprint, Resource } from '@sanity/blueprints-parser';
2
+ import type { DeploymentPlan } from '../../actions/blueprints/stacks.js';
2
3
  import { type BlueprintResourceRecord, type Stack } from '../types.js';
3
4
  export declare function formatTitle(title: string, name: string): string;
4
5
  export declare function formatDeployedResourceTree(resources: BlueprintResourceRecord[] | undefined, verbose?: boolean): string;
5
6
  export declare function formatResourceTree(resources: Resource[] | undefined, verbose?: boolean): string;
6
7
  export declare function formatStackInfo(stack: Stack | Blueprint, isCurrentStack?: boolean): string;
7
8
  export declare function formatStacksListing(stacks: Stack[], currentStackId?: string): string;
8
- export declare function stackDeployDiff(localBlueprint: Blueprint, deployedStack: Stack): string | null;
9
+ export declare function hasActionableChanges(deploymentPlan: DeploymentPlan): boolean;
10
+ export declare function formatDeploymentPlan(deploymentPlan: DeploymentPlan): string;
@@ -208,29 +208,143 @@ export function formatStacksListing(stacks, currentStackId) {
208
208
  }
209
209
  return output.join('\n');
210
210
  }
211
- export function stackDeployDiff(localBlueprint, deployedStack) {
212
- const added = [];
213
- const removed = [];
214
- // look for new resources
215
- for (const resource of localBlueprint.resources ?? []) {
216
- const deployedResource = deployedStack.resources.find(({ name, type }) => resource.name === name && resource.type === type);
217
- if (!deployedResource)
218
- added.push(resource);
219
- }
220
- // look for destroyed resources
221
- for (const resource of deployedStack.resources) {
222
- const localResource = localBlueprint.resources?.find(({ name, type }) => resource.name === name && resource.type === type);
223
- if (!localResource)
224
- removed.push(resource);
225
- }
226
- if (added.length === 0 && removed.length === 0)
227
- return null;
228
- const output = [];
229
- if (added.length > 0) {
230
- output.push(` ${styleText(['bold', 'greenBright'], '++')} ${added.map(({ name }) => styleText(['bgGreen', 'whiteBright'], `"${name}"`)).join(' ')}`);
211
+ const IGNORED_PARAMS = {
212
+ [SANITY_FUNCTION_DOCUMENT]: new Set(['src']),
213
+ [SANITY_FUNCTION_MEDIA_LIBRARY_ASSET]: new Set(['src']),
214
+ [SANITY_FUNCTION_SCHEDULE]: new Set(['src']),
215
+ };
216
+ const ASSET_RESOURCE_TYPES = new Set([
217
+ SANITY_FUNCTION_DOCUMENT,
218
+ SANITY_FUNCTION_MEDIA_LIBRARY_ASSET,
219
+ SANITY_FUNCTION_SCHEDULE,
220
+ ]);
221
+ function stringifyUnknown(val) {
222
+ if (val === null || val === undefined)
223
+ return JSON.stringify(val);
224
+ if (Array.isArray(val))
225
+ return `[${val.map(stringifyUnknown).join(',')}]`;
226
+ if (typeof val === 'object') {
227
+ const obj = val;
228
+ const sorted = Object.keys(obj)
229
+ .sort()
230
+ .map((k) => `${JSON.stringify(k)}:${stringifyUnknown(obj[k])}`);
231
+ return `{${sorted.join(',')}}`;
231
232
  }
232
- if (removed.length > 0) {
233
- output.push(` ${styleText(['bold', 'redBright'], '--')} ${removed.map(({ name }) => styleText(['bgRed', 'whiteBright'], `"${name}"`)).join(' ')}`);
233
+ return JSON.stringify(val);
234
+ }
235
+ function detectChangedParams(details) {
236
+ const existing = details.existingResource?.parameters;
237
+ const updated = details.updatedResource?.parameters;
238
+ if (!existing || !updated)
239
+ return [];
240
+ const ignored = IGNORED_PARAMS[details.type];
241
+ const keys = new Set([...Object.keys(existing), ...Object.keys(updated)]);
242
+ const changed = [];
243
+ for (const key of keys) {
244
+ if (ignored?.has(key))
245
+ continue;
246
+ if (stringifyUnknown(existing[key]) !== stringifyUnknown(updated[key])) {
247
+ changed.push(key);
248
+ }
234
249
  }
235
- return output.join('\n');
250
+ return changed;
251
+ }
252
+ function getAssetSrc(details) {
253
+ const src = details.updatedResource?.parameters?.src ?? details.parameters?.src;
254
+ return typeof src === 'string' && !src.startsWith('AS-') ? src : undefined;
255
+ }
256
+ function actionStyle(type, isUnchanged = false) {
257
+ if (isUnchanged)
258
+ return { icon: '=', color: 'dim' };
259
+ switch (type) {
260
+ case 'create':
261
+ return { icon: '+', color: 'green' };
262
+ case 'update':
263
+ return { icon: '~', color: 'yellow' };
264
+ case 'destroy':
265
+ return { icon: '-', color: 'red' };
266
+ case 'attach':
267
+ return { icon: '←', color: 'blue' };
268
+ case 'detach':
269
+ return { icon: '→', color: 'gray' };
270
+ // case 'skip':
271
+ default:
272
+ return { icon: '=', color: 'dim' };
273
+ }
274
+ }
275
+ export function hasActionableChanges(deploymentPlan) {
276
+ const { summary } = deploymentPlan;
277
+ if (summary.createCount || summary.destroyCount || summary.attachCount || summary.detachCount) {
278
+ return true;
279
+ }
280
+ return deploymentPlan.plan.some((action) => action.type === 'update' &&
281
+ action.details &&
282
+ (detectChangedParams(action.details).length > 0 ||
283
+ (ASSET_RESOURCE_TYPES.has(action.resourceType) &&
284
+ getAssetSrc(action.details) !== undefined)));
285
+ }
286
+ export function formatDeploymentPlan(deploymentPlan) {
287
+ const lines = [];
288
+ lines.push(styleText('bold', 'Deployment Plan'));
289
+ if (deploymentPlan.plan.length === 0) {
290
+ lines.push(styleText('dim', ' No changes'));
291
+ return lines.join('\n');
292
+ }
293
+ const typePad = Math.max(...deploymentPlan.plan.map((a) => a.type.length));
294
+ const names = deploymentPlan.plan.map((a) => a.details?.name ?? '');
295
+ const namePad = Math.max(...names.map((n) => n.length));
296
+ const enrichedPlan = deploymentPlan.plan.map((action) => {
297
+ const changedParams = action.type === 'update' && action.details ? detectChangedParams(action.details) : [];
298
+ // always represent asset updates as a change; hard to know if the asset has changed
299
+ const assetSrc = action.details && ASSET_RESOURCE_TYPES.has(action.resourceType)
300
+ ? getAssetSrc(action.details)
301
+ : undefined;
302
+ const isUnchanged = action.type === 'update' && changedParams.length === 0 && !assetSrc;
303
+ return { action, changedParams, isUnchanged };
304
+ });
305
+ let unchangedCount = 0;
306
+ for (const { action, changedParams, isUnchanged } of enrichedPlan) {
307
+ const name = action.details?.name ?? '';
308
+ if (isUnchanged)
309
+ unchangedCount++;
310
+ const style = actionStyle(action.type, isUnchanged);
311
+ const icon = styleText(['bold', style.color], style.icon);
312
+ const typeLabel = styleText(style.color, action.type.padEnd(typePad));
313
+ const nameLabel = isUnchanged
314
+ ? styleText('dim', name.padEnd(namePad))
315
+ : styleText('bold', name.padEnd(namePad));
316
+ const resourceType = styleText('dim', action.resourceType);
317
+ let suffix = '';
318
+ if (changedParams.length > 0) {
319
+ suffix = ` ${styleText('yellow', changedParams.join(', '))}`;
320
+ }
321
+ lines.push(` ${icon} ${typeLabel} ${nameLabel} ${resourceType}${suffix}`);
322
+ }
323
+ const { summary } = deploymentPlan;
324
+ const parts = [];
325
+ if (summary.createCount)
326
+ parts.push(styleText('green', `${summary.createCount} create`));
327
+ if (summary.updateCount) {
328
+ let label = `${summary.updateCount} update`;
329
+ if (unchangedCount > 0 && unchangedCount < summary.updateCount) {
330
+ label += ` (${styleText('yellow', `${summary.updateCount - unchangedCount} changed`)})`;
331
+ }
332
+ else if (unchangedCount === summary.updateCount) {
333
+ label += ` ${styleText('dim', '(no changes)')}`;
334
+ }
335
+ parts.push(label);
336
+ }
337
+ if (summary.destroyCount)
338
+ parts.push(styleText('red', `${summary.destroyCount} destroy`));
339
+ if (summary.skipCount)
340
+ parts.push(styleText('dim', `${summary.skipCount} skip`));
341
+ if (summary.attachCount)
342
+ parts.push(styleText('blue', `${summary.attachCount} attach`));
343
+ if (summary.detachCount)
344
+ parts.push(styleText('dim', `${summary.detachCount} detach`));
345
+ if (parts.length > 0) {
346
+ lines.push('');
347
+ lines.push(` ${parts.join(', ')}`);
348
+ }
349
+ return lines.join('\n');
236
350
  }
@@ -1,7 +1,10 @@
1
+ import { env } from 'node:process';
1
2
  import { createTracedFetch } from '../traced-fetch.js';
2
3
  export async function getLatestNpmVersion(pkg, logger) {
3
4
  const fetchFn = createTracedFetch(logger);
4
- const url = `https://registry.npmjs.org/${pkg}/latest`;
5
+ // allow for registry override; helpful for testing
6
+ const registry = env.SANITY_INTERNAL_NPM_REGISTRY_URL || 'https://registry.npmjs.org';
7
+ const url = `${registry}/${pkg}/latest`;
5
8
  try {
6
9
  const res = await fetchFn(url);
7
10
  if (!res.ok)
@@ -318,7 +318,7 @@
318
318
  "blueprints:deploy": {
319
319
  "aliases": [],
320
320
  "args": {},
321
- "description": "Pushes your local Blueprint configuration to the remote Stack; provisioning, updating, or destroying resources as needed. This is the primary command for applying infrastructure changes.\n\nBefore deploying, run 'blueprints plan' to preview changes. After deployment, use 'blueprints info' to verify Stack status or 'blueprints logs' to monitor activity.\n\nUse --no-wait to queue the deployment and return immediately without waiting for completion.",
321
+ "description": "Pushes your local Blueprint configuration to the remote Stack; provisioning, updating, or destroying resources as needed. This is the primary command for applying infrastructure changes.\n\nBefore deploying, run 'blueprints plan' to preview changes. After deployment, use 'blueprints info' to verify Stack status or 'blueprints logs' to monitor activity.\n\nUse --no-wait to queue the deployment and return immediately without waiting for completion.\n\nSet SANITY_ASSET_TIMEOUT (seconds) to override the 60-second timeout for processing resource assets.",
322
322
  "examples": [
323
323
  "<%= config.bin %> <%= command.id %>",
324
324
  "<%= config.bin %> <%= command.id %> --no-wait"
@@ -578,10 +578,9 @@
578
578
  "type": "boolean"
579
579
  },
580
580
  "verbose": {
581
- "description": "Verbose output",
582
- "hidden": false,
581
+ "description": "Verbose output; defaults to true",
583
582
  "name": "verbose",
584
- "allowNo": false,
583
+ "allowNo": true,
585
584
  "type": "boolean"
586
585
  },
587
586
  "fix": {
@@ -2084,5 +2083,5 @@
2084
2083
  ]
2085
2084
  }
2086
2085
  },
2087
- "version": "14.0.0"
2086
+ "version": "14.1.0"
2088
2087
  }
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": "14.0.0",
4
+ "version": "14.1.0",
5
5
  "author": "Sanity Runtime Team",
6
6
  "type": "module",
7
7
  "license": "MIT",