@sanity/runtime-cli 14.13.3 → 15.0.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.
Files changed (65) hide show
  1. package/README.md +215 -118
  2. package/dist/actions/blueprints/blueprint.js +13 -11
  3. package/dist/actions/blueprints/logs.d.ts +2 -1
  4. package/dist/actions/blueprints/logs.js +4 -5
  5. package/dist/actions/blueprints/resources.js +1 -0
  6. package/dist/actions/blueprints/stacks.d.ts +3 -1
  7. package/dist/actions/blueprints/stacks.js +11 -2
  8. package/dist/actions/functions/test.js +1 -1
  9. package/dist/actions/sanity/access.d.ts +38 -0
  10. package/dist/actions/sanity/access.js +23 -0
  11. package/dist/actions/sanity/projects.d.ts +1 -1
  12. package/dist/baseCommands.d.ts +2 -0
  13. package/dist/baseCommands.js +8 -5
  14. package/dist/commands/blueprints/add.js +1 -1
  15. package/dist/commands/blueprints/deploy.d.ts +2 -0
  16. package/dist/commands/blueprints/deploy.js +6 -2
  17. package/dist/commands/blueprints/destroy.js +0 -2
  18. package/dist/commands/blueprints/info.d.ts +1 -0
  19. package/dist/commands/blueprints/info.js +3 -1
  20. package/dist/commands/blueprints/init.js +2 -0
  21. package/dist/commands/blueprints/logs.d.ts +5 -0
  22. package/dist/commands/blueprints/logs.js +26 -3
  23. package/dist/commands/blueprints/mint-deploy-token.d.ts +14 -0
  24. package/dist/commands/blueprints/mint-deploy-token.js +47 -0
  25. package/dist/commands/blueprints/plan.d.ts +2 -0
  26. package/dist/commands/blueprints/plan.js +8 -2
  27. package/dist/commands/blueprints/promote.d.ts +2 -1
  28. package/dist/commands/blueprints/promote.js +7 -2
  29. package/dist/commands/blueprints/stacks.js +1 -1
  30. package/dist/commands/functions/add.js +1 -1
  31. package/dist/cores/blueprints/config.js +34 -34
  32. package/dist/cores/blueprints/doctor.js +7 -7
  33. package/dist/cores/blueprints/init.js +99 -76
  34. package/dist/cores/blueprints/logs.d.ts +3 -0
  35. package/dist/cores/blueprints/logs.js +15 -9
  36. package/dist/cores/blueprints/mint-deploy-token.d.ts +15 -0
  37. package/dist/cores/blueprints/mint-deploy-token.js +111 -0
  38. package/dist/cores/blueprints/promote.d.ts +1 -0
  39. package/dist/cores/blueprints/promote.js +25 -4
  40. package/dist/cores/functions/add.js +4 -5
  41. package/dist/cores/index.d.ts +1 -2
  42. package/dist/cores/index.js +1 -3
  43. package/dist/index.d.ts +0 -18
  44. package/dist/index.js +0 -20
  45. package/dist/utils/clipboard.d.ts +14 -0
  46. package/dist/utils/clipboard.js +73 -0
  47. package/dist/utils/display/errors.d.ts +5 -1
  48. package/dist/utils/display/prompt.d.ts +55 -15
  49. package/dist/utils/display/prompt.js +271 -45
  50. package/oclif.manifest.json +320 -18
  51. package/package.json +21 -67
  52. package/dist/actions/blueprints/index.d.ts +0 -16
  53. package/dist/actions/blueprints/index.js +0 -10
  54. package/dist/actions/functions/index.d.ts +0 -4
  55. package/dist/actions/functions/index.js +0 -4
  56. package/dist/actions/sanity/index.d.ts +0 -1
  57. package/dist/actions/sanity/index.js +0 -1
  58. package/dist/cores/blueprints/index.d.ts +0 -20
  59. package/dist/cores/blueprints/index.js +0 -10
  60. package/dist/cores/functions/index.d.ts +0 -16
  61. package/dist/cores/functions/index.js +0 -8
  62. package/dist/utils/display/index.d.ts +0 -5
  63. package/dist/utils/display/index.js +0 -5
  64. package/dist/utils/index.d.ts +0 -8
  65. package/dist/utils/index.js +0 -8
@@ -1,5 +1,5 @@
1
1
  import { Flags } from '@oclif/core';
2
- import { ResolvedCommand } from '../../baseCommands.js';
2
+ import { projectIdFlagConfig, ResolvedCommand } from '../../baseCommands.js';
3
3
  import { blueprintPromoteCore } from '../../cores/blueprints/promote.js';
4
4
  import { Logger } from '../../utils/logger.js';
5
5
  export default class PromoteCommand extends ResolvedCommand {
@@ -8,20 +8,25 @@ export default class PromoteCommand extends ResolvedCommand {
8
8
  static description = `Promotes a deployed Stack to organization scope, enabling management of org-level resources. Promotion cannot be reversed.
9
9
 
10
10
  Your local Blueprint configuration will be updated to reflect the new scope.`;
11
- static hidden = true;
12
11
  static examples = [
13
12
  '<%= config.bin %> <%= command.id %>',
14
13
  '<%= config.bin %> <%= command.id %> --stack <name-or-id>',
14
+ '<%= config.bin %> <%= command.id %> --project-id <projectId> --stack <name-or-id>',
15
+ '<%= config.bin %> <%= command.id %> --new-stack-name <new-name>',
15
16
  ];
16
17
  static flags = {
17
18
  stack: Flags.string({
18
19
  description: 'Stack name or ID to promote',
19
20
  aliases: ['id'],
20
21
  }),
22
+ 'project-id': Flags.string({ ...projectIdFlagConfig }),
21
23
  force: Flags.boolean({
22
24
  description: 'Skip confirmation prompt',
23
25
  default: false,
24
26
  }),
27
+ 'new-stack-name': Flags.string({
28
+ description: 'Set a new name for the Stack while promoting',
29
+ }),
25
30
  };
26
31
  async run() {
27
32
  const result = await blueprintPromoteCore({
@@ -21,7 +21,7 @@ Use --include-projects with --organization-id to also list Stacks from all proje
21
21
  ...projectIdFlagConfig,
22
22
  exclusive: ['organization-id', 'include-projects'],
23
23
  }),
24
- 'organization-id': Flags.string({ ...organizationIdFlagConfig, exclusive: ['project-id'] }),
24
+ 'organization-id': Flags.string({ ...organizationIdFlagConfig }),
25
25
  'include-projects': Flags.boolean({
26
26
  description: 'Include Stacks from all projects within the organization. Requires --organization-id.',
27
27
  dependsOn: ['organization-id'],
@@ -1,7 +1,7 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { ResolvedCommand } from '../../baseCommands.js';
3
3
  import { FUNCTION_TYPES } from '../../constants.js';
4
- import { functionAddCore } from '../../cores/functions/index.js';
4
+ import { functionAddCore } from '../../cores/functions/add.js';
5
5
  import { Logger } from '../../utils/logger.js';
6
6
  import { INSTALLER_OPTIONS } from '../../utils/types.js';
7
7
  export default class AddCommand extends ResolvedCommand {
@@ -1,7 +1,7 @@
1
1
  import { patchConfigFile, writeConfigFile, } from '../../actions/blueprints/config.js';
2
2
  import { resolveStackIdByNameOrId } from '../../actions/blueprints/stacks.js';
3
3
  import { filePathRelativeToCwd, labeledId, warn } from '../../utils/display/presenters.js';
4
- import { promptForProject, promptForStack } from '../../utils/display/prompt.js';
4
+ import { runScopeAndStackWizard } from '../../utils/display/prompt.js';
5
5
  import { styleText } from '../../utils/style-text.js';
6
6
  export async function blueprintConfigCore(options) {
7
7
  const { bin = 'sanity', blueprint, log, token, flags } = options;
@@ -12,10 +12,10 @@ export async function blueprintConfigCore(options) {
12
12
  const hasConfigFile = !!blueprintConfig;
13
13
  if (!configStackId && !configScopeType && !configScopeId) {
14
14
  if (hasConfigFile) {
15
- log(warn('Incomplete configuration.'));
15
+ log(warn('Blueprint config (.sanity/blueprint.config.json) is incomplete.'));
16
16
  }
17
17
  else {
18
- log('No configuration file found.');
18
+ log('No Blueprint config found at .sanity/blueprint.config.json.');
19
19
  }
20
20
  if (!editConfig) {
21
21
  log(`Run \`npx ${bin} blueprints doctor\` for diagnostics.`);
@@ -26,7 +26,7 @@ export async function blueprintConfigCore(options) {
26
26
  printConfig({ configLabel: 'Current', log, config: blueprintConfig });
27
27
  // passing new config without --edit flag is not allowed
28
28
  if (providedConfigFlag && !editConfig) {
29
- log('To update the configuration, use the --edit flag.');
29
+ log('To update the Blueprint config, use the --edit flag.');
30
30
  return { success: true, json: blueprintConfig ? { config: blueprintConfig } : undefined };
31
31
  }
32
32
  if (!editConfig) {
@@ -70,44 +70,44 @@ export async function blueprintConfigCore(options) {
70
70
  return { success: true, json: { config: newConfig } };
71
71
  }
72
72
  catch {
73
- return { success: false, error: 'Unable to update configuration.' };
73
+ return {
74
+ success: false,
75
+ error: 'Could not update Blueprint config (.sanity/blueprint.config.json).',
76
+ };
74
77
  }
75
78
  }
76
79
  }
77
- // prompt for values interactively
78
- // do not yet offer organization as scope option
79
- let updatedProjectId = flagProjectId;
80
- if (!updatedProjectId) {
81
- const pickedProject = await promptForProject({
82
- token,
83
- knownProjectId: configScopeId,
84
- logger: log,
85
- });
86
- updatedProjectId = pickedProject.projectId;
87
- }
88
- if (!updatedProjectId)
89
- return { success: false, error: 'Project ID is required.' };
90
- let updatedStackId;
91
- if (flagStack) {
92
- updatedStackId = await resolveStackIdByNameOrId(flagStack, { token, scopeType: 'project', scopeId: updatedProjectId }, log);
93
- }
94
- if (!updatedStackId) {
95
- const pickedStack = await promptForStack({ projectId: updatedProjectId, token, logger: log });
96
- updatedStackId = pickedStack.stackId;
97
- }
98
- if (!updatedStackId)
99
- return { success: false, error: 'Stack is required.' };
80
+ // prompt for values interactively via wizard: org -> scope -> project (if needed) -> stack
81
+ log('');
82
+ const knownOrganizationId = configScopeType === 'organization' ? configScopeId : undefined;
83
+ const knownProjectId = configScopeType === 'project' ? configScopeId : undefined;
84
+ const wizardResult = await runScopeAndStackWizard({
85
+ token,
86
+ knownOrganizationId,
87
+ knownProjectId,
88
+ logger: log,
89
+ });
90
+ if (!wizardResult.scopeId)
91
+ return { success: false, error: 'Scope selection is required.' };
100
92
  try {
101
- // update or create config JSON
102
- const newConfig = writeConfigFile(blueprintFilePath, {
103
- projectId: updatedProjectId,
104
- stackId: updatedStackId,
105
- });
93
+ // update or create config JSON with correct scope fields
94
+ const configUpdate = { stackId: wizardResult.stackId };
95
+ if (wizardResult.scopeType === 'organization') {
96
+ configUpdate.organizationId = wizardResult.scopeId;
97
+ }
98
+ else {
99
+ configUpdate.projectId = wizardResult.scopeId;
100
+ }
101
+ const newConfig = writeConfigFile(blueprintFilePath, configUpdate);
102
+ log('');
106
103
  printConfig({ configLabel: hasConfigFile ? 'Updated' : 'New', log, config: newConfig });
107
104
  return { success: true, json: { config: newConfig } };
108
105
  }
109
106
  catch {
110
- return { success: false, error: 'Unable to update configuration!' };
107
+ return {
108
+ success: false,
109
+ error: 'Could not update Blueprint config (.sanity/blueprint.config.json).',
110
+ };
111
111
  }
112
112
  }
113
113
  }
@@ -15,7 +15,7 @@ import { blueprintConfigCore } from './config.js';
15
15
  const diagLookup = {
16
16
  online: 'Host online',
17
17
  tokenValid: 'Authenticated',
18
- blueprintValid: 'Blueprint valid',
18
+ blueprintValid: 'Manifest valid',
19
19
  stackReady: 'Stack ready',
20
20
  userHasAccess: 'User has access',
21
21
  };
@@ -24,9 +24,9 @@ function sourceLabel(source) {
24
24
  case 'env':
25
25
  return 'environment';
26
26
  case 'module':
27
- return 'blueprint module';
27
+ return 'manifest module';
28
28
  case 'config':
29
- return 'config file';
29
+ return '.sanity/blueprint.config.json';
30
30
  case 'inferred':
31
31
  return 'inferred';
32
32
  default:
@@ -91,7 +91,7 @@ export async function blueprintDoctorCore(options) {
91
91
  blueprintConfig,
92
92
  ...resolved,
93
93
  };
94
- envRows.push(['Blueprint', filePathRelativeToCwd(blueprint.fileInfo.blueprintFilePath)]);
94
+ envRows.push(['Manifest', filePathRelativeToCwd(blueprint.fileInfo.blueprintFilePath)]);
95
95
  if (blueprint.errors.length === 0) {
96
96
  diagnostics.blueprintValid = { status: true };
97
97
  }
@@ -129,7 +129,7 @@ export async function blueprintDoctorCore(options) {
129
129
  configRows.push(['Source', filePathRelativeToCwd(blueprintConfig.configPath)]);
130
130
  }
131
131
  else if (!blueprintConfig) {
132
- configRows.push(['Source', styleText('dim', 'no config file')]);
132
+ configRows.push(['Source', styleText('dim', 'no .sanity/blueprint.config.json')]);
133
133
  }
134
134
  if (blueprintConfig?.updatedAt) {
135
135
  configRows.push(['Updated', new Date(blueprintConfig.updatedAt).toLocaleString('sv-SE')]);
@@ -315,8 +315,8 @@ export async function blueprintDoctorCore(options) {
315
315
  if (!localBlueprint) {
316
316
  return {
317
317
  success: false,
318
- error: `${errorMessage}. Unable to fix: Blueprint is missing or invalid`,
319
- suggestions: [`Run \`npx ${bin} blueprints init\` to create a new Blueprint.`],
318
+ error: `${errorMessage}. Unable to fix: Blueprint manifest is missing or invalid`,
319
+ suggestions: [`Run \`npx ${bin} blueprints init\` to create a new Blueprint manifest.`],
320
320
  data: { diagnostics: flatDiagnostics },
321
321
  };
322
322
  }
@@ -3,18 +3,24 @@ import { join } from 'node:path';
3
3
  import { confirm } from '@inquirer/prompts';
4
4
  import { findBlueprintFile, readLocalBlueprint, writeBlueprintToDisk, } from '../../actions/blueprints/blueprint.js';
5
5
  import { writeConfigFile } from '../../actions/blueprints/config.js';
6
- import { createEmptyStack } from '../../actions/blueprints/stacks.js';
6
+ import { createEmptyStack, getStack } from '../../actions/blueprints/stacks.js';
7
7
  import { writeGitignoreFile } from '../../actions/git.js';
8
8
  import { writeOrUpdateNodeDependency } from '../../actions/node.js';
9
9
  import { verifyExampleExists, writeExample } from '../../actions/sanity/examples.js';
10
10
  import { getProject } from '../../actions/sanity/projects.js';
11
11
  import { BLUEPRINT_CONFIG_DIR, BLUEPRINT_CONFIG_FILE } from '../../config.js';
12
12
  import { check, filePathRelativeToCwd, labeledId, warn } from '../../utils/display/presenters.js';
13
- import { promptForBlueprintType, promptForProject, promptForStack, } from '../../utils/display/prompt.js';
13
+ import { promptForBlueprintType, promptForOrganization, promptForProjectInOrg, promptForStack, runScopeAndStackWizard, } from '../../utils/display/prompt.js';
14
14
  import { styleText } from '../../utils/style-text.js';
15
15
  import { blueprintConfigCore } from './config.js';
16
- const SCOPE_PROJECT = 'project';
17
- const SCOPE_ORGANIZATION = 'organization';
16
+ /** Derive scope type and ID from mutually exclusive flag values. */
17
+ function resolveScopeFromFlags(projectId, organizationId) {
18
+ if (projectId)
19
+ return { scopeType: 'project', scopeId: projectId };
20
+ if (organizationId)
21
+ return { scopeType: 'organization', scopeId: organizationId };
22
+ return undefined;
23
+ }
18
24
  export async function blueprintInitCore(options) {
19
25
  const { bin = 'sanity', log, token, knownProjectId, args, flags, validateResources } = options;
20
26
  const { dir: flagDir, example: flagExample, 'blueprint-type': flagBlueprintType, 'project-id': flagProjectId, 'organization-id': flagOrganizationId, 'stack-id': flagStackId, 'stack-name': flagStackName, verbose = false, } = flags;
@@ -87,23 +93,15 @@ async function handleExistingBlueprint(options) {
87
93
  error: 'Flag --example cannot be used with an existing Blueprint.',
88
94
  };
89
95
  }
90
- // existing blueprint file, user wants to configure a stack
91
- // --stack-name means they want a new stack created
96
+ // --stack-name with a scope flag means create a new stack before configuring
92
97
  let resolvedStackId = flagStackId;
93
98
  if (!resolvedStackId && flagStackName) {
94
- const scopeId = flagProjectId || flagOrganizationId;
95
- let scopeType;
96
- if (flagProjectId)
97
- scopeType = SCOPE_PROJECT;
98
- else if (flagOrganizationId)
99
- scopeType = SCOPE_ORGANIZATION;
100
- if (scopeType && scopeId) {
101
- // have what we need to create a new stack
102
- log(`\nCreating new Stack "${flagStackName}" scoped to ${labeledId(scopeType, scopeId)}`);
99
+ const scope = resolveScopeFromFlags(flagProjectId, flagOrganizationId);
100
+ if (scope) {
101
+ log(`\nCreating new Stack "${flagStackName}" scoped to ${labeledId(scope.scopeType, scope.scopeId)}`);
103
102
  const stack = await createEmptyStack({
104
103
  token,
105
- scopeType,
106
- scopeId,
104
+ ...scope,
107
105
  name: flagStackName,
108
106
  logger: log,
109
107
  });
@@ -168,8 +166,11 @@ export function validateFlags(flags) {
168
166
  if (stackId && stackName) {
169
167
  return { success: false, error: 'Cannot specify both --stack-id and --stack-name' };
170
168
  }
171
- if (organizationId && projectId) {
172
- return { success: false, error: 'Cannot specify both --organization-id and --project-id' };
169
+ if ((stackId || stackName) && !organizationId && !projectId) {
170
+ return {
171
+ success: false,
172
+ error: `${stackId ? '--stack-id' : '--stack-name'} requires --project-id or --organization-id`,
173
+ };
173
174
  }
174
175
  return null;
175
176
  }
@@ -184,10 +185,20 @@ async function handleExampleInitialization(options) {
184
185
  if (!exampleExists) {
185
186
  return { success: false, error: `Blueprint example "${exampleName}" does not exist.` };
186
187
  }
187
- const resolvedProjectId = projectId || (await promptForProject({ token, logger: log })).projectId;
188
+ let resolvedProjectId = projectId;
189
+ if (!resolvedProjectId) {
190
+ // examples are project-scoped; use the composable prompts to pick an org then a project
191
+ const orgResult = await promptForOrganization({ token, logger: log });
192
+ if (typeof orgResult === 'symbol')
193
+ throw new Error('Unexpected back navigation');
194
+ const picked = await promptForProjectInOrg({ projects: orgResult.projects, logger: log });
195
+ if (typeof picked === 'symbol')
196
+ throw new Error('Unexpected back navigation');
197
+ resolvedProjectId = picked.projectId;
198
+ }
188
199
  const stack = await createEmptyStack({
189
200
  token,
190
- scopeType: SCOPE_PROJECT,
201
+ scopeType: 'project',
191
202
  scopeId: resolvedProjectId,
192
203
  name: `example-${exampleName}`,
193
204
  logger: log,
@@ -225,69 +236,81 @@ async function handleExampleInitialization(options) {
225
236
  }
226
237
  export async function resolveScopeAndStack(params) {
227
238
  const { projectId, organizationId, stackId, stackName, knownProjectId, log, token } = params;
228
- let scopeType = SCOPE_PROJECT;
229
- let scopeId;
230
- if (projectId) {
231
- scopeType = SCOPE_PROJECT;
232
- scopeId = projectId;
233
- }
234
- else if (organizationId) {
235
- scopeType = SCOPE_ORGANIZATION;
236
- scopeId = organizationId;
239
+ const flagScope = resolveScopeFromFlags(projectId, organizationId);
240
+ // --- Branch 1: Scope provided via flags ---
241
+ if (flagScope) {
242
+ const { scopeType, scopeId } = flagScope;
243
+ let resolvedStackId = stackId;
244
+ // --stack-name: create a new stack
245
+ if (!resolvedStackId && stackName) {
246
+ log(`\nCreating new Stack "${stackName}" scoped to ${labeledId(scopeType, scopeId)}`);
247
+ const stack = await createEmptyStack({
248
+ token,
249
+ scopeType,
250
+ scopeId,
251
+ name: stackName,
252
+ logger: log,
253
+ });
254
+ resolvedStackId = stack.id;
255
+ }
256
+ // --stack-id: validate it exists
257
+ if (resolvedStackId && resolvedStackId === stackId) {
258
+ const spinner = log.ora('Validating Stack...').start();
259
+ const { ok, error } = await getStack({
260
+ stackId: resolvedStackId,
261
+ auth: { token, scopeType, scopeId },
262
+ logger: log,
263
+ });
264
+ spinner.stop();
265
+ if (!ok) {
266
+ throw new Error(error || `Stack "${resolvedStackId}" not found for ${scopeType} "${scopeId}".`);
267
+ }
268
+ }
269
+ // No stack flag: prompt for stack selection
270
+ if (!resolvedStackId) {
271
+ log('\nBlueprints are deployed to a "Stack".');
272
+ const stackResult = await promptForStack({ scopeType, scopeId, token, logger: log });
273
+ if (typeof stackResult === 'symbol')
274
+ throw new Error('Unexpected back navigation');
275
+ resolvedStackId = stackResult.stackId;
276
+ }
277
+ return { scopeType, scopeId, stackId: resolvedStackId };
237
278
  }
238
- let resolvedStackId = stackId;
239
- if (!resolvedStackId && stackName && scopeType && scopeId) {
240
- // sending stackName will assume you want to create a new stack
241
- // essentially the only way to create an org-scoped stack
242
- log(`\nCreating new Stack "${stackName}" scoped to ${labeledId(scopeType, scopeId)}`);
243
- const stack = await createEmptyStack({
279
+ // --- Branch 2: No scope flags -- interactive resolution ---
280
+ log('\nBlueprints are scoped to an organization or project.');
281
+ log(styleText('dim', 'Scope determines which resources your Blueprint can manage.\n' +
282
+ 'Organization scope covers all projects; project scope is limited to one.'));
283
+ // Offer the CLI's configured project as a shortcut
284
+ if (knownProjectId) {
285
+ const { ok, project } = await getProject({
244
286
  token,
245
- scopeType,
246
- scopeId,
247
- name: stackName,
287
+ scopeId: knownProjectId,
288
+ scopeType: 'project',
248
289
  logger: log,
249
290
  });
250
- resolvedStackId = stack.id;
251
- }
252
- if (!scopeId) {
253
- log('\nBlueprints are associated with a Sanity project.');
254
- // If we have a CLI project ID, offer it as a suggestion
255
- if (knownProjectId) {
256
- const { ok, project } = await getProject({
257
- token,
258
- scopeId: knownProjectId,
259
- scopeType: 'project',
260
- logger: log,
291
+ if (ok && project) {
292
+ const useCliProject = await confirm({
293
+ message: `The CLI is configured to use "${project.displayName}" (${knownProjectId}). Use this for the blueprint?`,
294
+ default: true,
261
295
  });
262
- if (ok && project) {
263
- const useCliProject = await confirm({
264
- message: `The CLI is configured to use "${project.displayName}" (${knownProjectId}). Use this for the blueprint?`,
265
- default: true,
296
+ if (useCliProject) {
297
+ // Scope locked to project; just need a stack
298
+ log('\nBlueprints are deployed to a "Stack".');
299
+ const stackResult = await promptForStack({
300
+ scopeType: 'project',
301
+ scopeId: knownProjectId,
302
+ token,
303
+ logger: log,
266
304
  });
267
- if (useCliProject) {
268
- scopeType = SCOPE_PROJECT;
269
- scopeId = knownProjectId;
270
- }
305
+ if (typeof stackResult === 'symbol')
306
+ throw new Error('Unexpected back navigation');
307
+ return { scopeType: 'project', scopeId: knownProjectId, stackId: stackResult.stackId };
271
308
  }
272
309
  }
273
- // If still no scope (no knownProjectId, lookup failed, or user declined), prompt for selection
274
- if (!scopeId) {
275
- log('Select a project:');
276
- const pickedProject = await promptForProject({ token, logger: log });
277
- scopeType = SCOPE_PROJECT;
278
- scopeId = pickedProject.projectId;
279
- }
280
310
  }
281
- if (!resolvedStackId) {
282
- log('\nBlueprints are deployed to a "Stack".');
283
- const { stackId } = await promptForStack({ projectId: scopeId, token, logger: log });
284
- resolvedStackId = stackId;
285
- }
286
- return {
287
- scopeType,
288
- scopeId,
289
- stackId: resolvedStackId,
290
- };
311
+ // Full wizard: org -> scope -> project (if needed) -> stack
312
+ const wizard = await runScopeAndStackWizard({ token, logger: log });
313
+ return { scopeType: wizard.scopeType, scopeId: wizard.scopeId, stackId: wizard.stackId };
291
314
  }
292
315
  export async function determineBlueprintExtension(params) {
293
316
  const { requestedType, blueprintDir, log } = params;
@@ -329,7 +352,7 @@ export async function createBlueprintFiles(params) {
329
352
  log(check(`${styleText('bold', 'Created Blueprint:')} ${displayPath}/${blueprintFileName}`));
330
353
  writeConfigFile(blueprintFilePath, {
331
354
  stackId,
332
- ...(scopeType === SCOPE_ORGANIZATION ? { organizationId: scopeId } : { projectId: scopeId }),
355
+ ...(scopeType === 'organization' ? { organizationId: scopeId } : { projectId: scopeId }),
333
356
  });
334
357
  log(check(`${styleText('bold', 'Added configuration:')} ${displayPath}/${BLUEPRINT_CONFIG_DIR}/${BLUEPRINT_CONFIG_FILE}`));
335
358
  const gitignoreResult = writeGitignoreFile(blueprintFilePath);
@@ -7,6 +7,9 @@ export interface BlueprintLogsOptions extends CoreConfig {
7
7
  flags: {
8
8
  watch?: boolean;
9
9
  verbose?: boolean;
10
+ limit?: number;
11
+ since?: string;
12
+ before?: string;
10
13
  };
11
14
  }
12
15
  export declare function blueprintLogsCore(options: BlueprintLogsOptions): Promise<CoreResult>;
@@ -1,16 +1,18 @@
1
- import { getLogs, getRecentLogs } from '../../actions/blueprints/logs.js';
1
+ import { getLogs } from '../../actions/blueprints/logs.js';
2
2
  import { setupLogPolling } from '../../actions/blueprints/logs-polling.js';
3
3
  import { formatTitle } from '../../utils/display/blueprints-formatting.js';
4
4
  import { formatLogEntry, formatLogs } from '../../utils/display/logs-formatting.js';
5
5
  import { niceId } from '../../utils/display/presenters.js';
6
6
  import { styleText } from '../../utils/style-text.js';
7
+ /** Number of recent logs to show before entering watch mode */
8
+ const WATCH_PREFETCH_LIMIT = 10;
7
9
  export async function blueprintLogsCore(options) {
8
10
  const { log, auth, stackId, deployedStack, flags } = options;
9
- const { watch = false, verbose = false } = flags;
11
+ const { watch = false, verbose = false, limit, since, before } = flags;
10
12
  const spinner = log.ora(`Fetching recent logs for Stack deployment ${niceId(stackId)}`).start();
11
13
  try {
12
14
  if (watch) {
13
- const { ok, logs, error } = await getLogs({ stackId }, auth, log);
15
+ const { ok, logs, error } = await getLogs({ stackId, limit: WATCH_PREFETCH_LIMIT }, auth, log);
14
16
  if (!ok) {
15
17
  spinner.fail(`${styleText('red', 'Failed')} to retrieve logs`);
16
18
  log.error(`Error: ${error || 'Unknown error'}`);
@@ -20,9 +22,10 @@ export async function blueprintLogsCore(options) {
20
22
  log(`${formatTitle('Blueprint', deployedStack.name)} ${niceId(stackId)} logs`);
21
23
  if (logs.length > 0) {
22
24
  log('\nMost recent logs:');
23
- const recentLogs = getRecentLogs(logs);
25
+ // API returns newest first; display in chronological order
26
+ const orderedLogs = [...logs].reverse();
24
27
  let previousLog;
25
- for (const logEntry of recentLogs) {
28
+ for (const logEntry of orderedLogs) {
26
29
  log(formatLogEntry(logEntry, verbose, previousLog));
27
30
  previousLog = logEntry;
28
31
  }
@@ -39,14 +42,14 @@ export async function blueprintLogsCore(options) {
39
42
  showBanner: true,
40
43
  verbose,
41
44
  });
42
- // Return a special key for polling mode
45
+ // Return a never-resolving promise so the polling loop keeps running
43
46
  return {
44
47
  success: true,
45
48
  streaming: new Promise(() => { }),
46
49
  };
47
50
  }
48
- // Regular non-streaming logs
49
- const { ok, logs, error } = await getLogs({ stackId }, auth, log);
51
+ // One-shot fetch (no watch)
52
+ const { ok, logs, error, hasMore } = await getLogs({ stackId, limit, before, after: since }, auth, log);
50
53
  if (!ok) {
51
54
  spinner.fail(`${styleText('red', 'Failed')} to retrieve Stack deployment logs`);
52
55
  log.error(`Error: ${error || 'Unknown error'}`);
@@ -59,7 +62,10 @@ export async function blueprintLogsCore(options) {
59
62
  spinner.succeed(`${formatTitle('Blueprint', deployedStack.name)} Logs`);
60
63
  log(`Found ${styleText('bold', logs.length.toString())} log entries for Stack deployment ${niceId(stackId)}\n`);
61
64
  log(formatLogs(logs, verbose));
62
- return { success: true, json: { logs } };
65
+ if (hasMore) {
66
+ log(`\n${styleText('dim', 'More logs available. Narrow the range with --before <timestamp> or raise --limit (max 500).')}`);
67
+ }
68
+ return { success: true, json: { logs, hasMore } };
63
69
  }
64
70
  catch (err) {
65
71
  spinner.fail('Failed to retrieve Stack deployment logs');
@@ -0,0 +1,15 @@
1
+ import type { AuthParams, ScopeType } from '../../utils/types.js';
2
+ import type { CoreConfig, CoreResult } from '../index.js';
3
+ export interface BlueprintMintDeployTokenOptions extends CoreConfig {
4
+ auth: AuthParams;
5
+ scopeType: ScopeType;
6
+ scopeId: string;
7
+ flags: {
8
+ label?: string;
9
+ print?: boolean;
10
+ json?: boolean;
11
+ verbose?: boolean;
12
+ };
13
+ }
14
+ export declare function defaultLabel(scopeType: ScopeType): string;
15
+ export declare function blueprintMintDeployTokenCore(options: BlueprintMintDeployTokenOptions): Promise<CoreResult>;
@@ -0,0 +1,111 @@
1
+ import { hostname } from 'node:os';
2
+ import { confirm, input, select } from '@inquirer/prompts';
3
+ import { createRobotToken } from '../../actions/sanity/access.js';
4
+ import { ClipboardUnavailableError, write as clipboardWrite } from '../../utils/clipboard.js';
5
+ import { styleText } from '../../utils/style-text.js';
6
+ const ROLE_BY_SCOPE = {
7
+ project: 'blueprints-deployer',
8
+ organization: 'blueprints-deployer-robot',
9
+ };
10
+ export function defaultLabel(scopeType) {
11
+ const date = new Date().toISOString().slice(0, 10);
12
+ let host = 'unknown-host';
13
+ try {
14
+ host = hostname();
15
+ }
16
+ catch { }
17
+ return `blueprints-deployer @ ${host} ${date} (${scopeType})`;
18
+ }
19
+ export async function blueprintMintDeployTokenCore(options) {
20
+ const { log, auth, scopeType, scopeId, flags } = options;
21
+ const label = flags.label ?? defaultLabel(scopeType);
22
+ const roleName = ROLE_BY_SCOPE[scopeType];
23
+ const spinner = log.ora('Minting deploy token...').start();
24
+ const { ok, error, robot } = await createRobotToken({
25
+ auth,
26
+ resourceType: scopeType,
27
+ resourceId: scopeId,
28
+ body: {
29
+ label,
30
+ memberships: [{ resourceType: scopeType, resourceId: scopeId, roleNames: [roleName] }],
31
+ },
32
+ logger: log,
33
+ });
34
+ if (!ok || !robot) {
35
+ spinner.fail('Failed to mint deploy token');
36
+ const suggestions = [];
37
+ if (error && /access|permission|forbidden|role/i.test(error)) {
38
+ suggestions.push(scopeType === 'organization'
39
+ ? 'Verify you have organization admin access.'
40
+ : 'Verify you have project admin access.');
41
+ }
42
+ return { success: false, error: error || 'Failed to mint deploy token', suggestions };
43
+ }
44
+ spinner.stop().clear();
45
+ if (flags.print) {
46
+ log(robot.token);
47
+ return { success: true, json: serializeRobot(robot) };
48
+ }
49
+ if (flags.json) {
50
+ return { success: true, json: serializeRobot(robot) };
51
+ }
52
+ log(styleText('green', `Minted "${robot.label}" (${robot.id})`));
53
+ if (robot.expiresAt) {
54
+ log(styleText('dim', `Expires: ${robot.expiresAt}`));
55
+ }
56
+ const action = await select({
57
+ message: 'What would you like to do with the token?',
58
+ choices: [
59
+ { name: 'Copy to clipboard', value: 'copy' },
60
+ { name: 'Print to terminal', value: 'print' },
61
+ { name: 'Exit (token will be lost)', value: 'exit' },
62
+ ],
63
+ default: 'copy',
64
+ });
65
+ if (action === 'copy') {
66
+ try {
67
+ await clipboardWrite(robot.token);
68
+ log(styleText('green', 'Token copied to clipboard.'));
69
+ }
70
+ catch (err) {
71
+ const msg = err instanceof ClipboardUnavailableError
72
+ ? err.message
73
+ : err instanceof Error
74
+ ? err.message
75
+ : String(err);
76
+ log.warn(styleText('yellow', `Could not copy to clipboard: ${msg}`));
77
+ const printInstead = await confirm({
78
+ message: 'Print the token to the terminal instead?',
79
+ default: false,
80
+ });
81
+ if (printInstead) {
82
+ await printAndConfirm(log, robot.token);
83
+ }
84
+ else {
85
+ log(styleText('yellow', 'Token discarded. You can mint a new one anytime.'));
86
+ }
87
+ }
88
+ }
89
+ else if (action === 'print') {
90
+ await printAndConfirm(log, robot.token);
91
+ }
92
+ else {
93
+ log(styleText('yellow', 'Token discarded. You can mint a new one anytime.'));
94
+ }
95
+ return { success: true, json: serializeRobot(robot) };
96
+ }
97
+ /**
98
+ * Show the token inside an inquirer prompt and rely on `clearPromptOnDone`
99
+ * so inquirer wipes the entire rendered block (token included) on Enter.
100
+ */
101
+ async function printAndConfirm(log, token) {
102
+ await input({
103
+ message: `\n${token}\n\nPress Enter once you have copied the token (it will be erased):`,
104
+ default: '',
105
+ theme: { prefix: '' },
106
+ }, { clearPromptOnDone: true });
107
+ log(styleText('dim', 'Token erased.'));
108
+ }
109
+ function serializeRobot(robot) {
110
+ return robot;
111
+ }