@sanity/runtime-cli 14.13.4 → 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 (63) hide show
  1. package/README.md +215 -118
  2. package/dist/actions/blueprints/logs.d.ts +2 -1
  3. package/dist/actions/blueprints/logs.js +4 -5
  4. package/dist/actions/blueprints/resources.js +1 -0
  5. package/dist/actions/blueprints/stacks.d.ts +3 -1
  6. package/dist/actions/blueprints/stacks.js +11 -2
  7. package/dist/actions/functions/test.js +1 -1
  8. package/dist/actions/sanity/access.d.ts +38 -0
  9. package/dist/actions/sanity/access.js +23 -0
  10. package/dist/actions/sanity/projects.d.ts +1 -1
  11. package/dist/baseCommands.d.ts +2 -0
  12. package/dist/baseCommands.js +3 -0
  13. package/dist/commands/blueprints/add.js +1 -1
  14. package/dist/commands/blueprints/deploy.d.ts +2 -0
  15. package/dist/commands/blueprints/deploy.js +6 -2
  16. package/dist/commands/blueprints/destroy.js +0 -2
  17. package/dist/commands/blueprints/info.d.ts +1 -0
  18. package/dist/commands/blueprints/info.js +3 -1
  19. package/dist/commands/blueprints/init.js +2 -0
  20. package/dist/commands/blueprints/logs.d.ts +5 -0
  21. package/dist/commands/blueprints/logs.js +26 -3
  22. package/dist/commands/blueprints/mint-deploy-token.d.ts +14 -0
  23. package/dist/commands/blueprints/mint-deploy-token.js +47 -0
  24. package/dist/commands/blueprints/plan.d.ts +2 -0
  25. package/dist/commands/blueprints/plan.js +8 -2
  26. package/dist/commands/blueprints/promote.d.ts +2 -1
  27. package/dist/commands/blueprints/promote.js +7 -2
  28. package/dist/commands/blueprints/stacks.js +1 -1
  29. package/dist/commands/functions/add.js +1 -1
  30. package/dist/cores/blueprints/config.js +23 -29
  31. package/dist/cores/blueprints/init.js +99 -76
  32. package/dist/cores/blueprints/logs.d.ts +3 -0
  33. package/dist/cores/blueprints/logs.js +15 -9
  34. package/dist/cores/blueprints/mint-deploy-token.d.ts +15 -0
  35. package/dist/cores/blueprints/mint-deploy-token.js +111 -0
  36. package/dist/cores/blueprints/promote.d.ts +1 -0
  37. package/dist/cores/blueprints/promote.js +22 -1
  38. package/dist/cores/functions/add.js +4 -5
  39. package/dist/cores/index.d.ts +1 -2
  40. package/dist/cores/index.js +0 -2
  41. package/dist/index.d.ts +0 -18
  42. package/dist/index.js +0 -20
  43. package/dist/utils/clipboard.d.ts +14 -0
  44. package/dist/utils/clipboard.js +73 -0
  45. package/dist/utils/display/errors.d.ts +5 -1
  46. package/dist/utils/display/prompt.d.ts +55 -15
  47. package/dist/utils/display/prompt.js +271 -45
  48. package/oclif.manifest.json +320 -18
  49. package/package.json +21 -67
  50. package/dist/actions/blueprints/index.d.ts +0 -16
  51. package/dist/actions/blueprints/index.js +0 -10
  52. package/dist/actions/functions/index.d.ts +0 -4
  53. package/dist/actions/functions/index.js +0 -4
  54. package/dist/actions/sanity/index.d.ts +0 -1
  55. package/dist/actions/sanity/index.js +0 -1
  56. package/dist/cores/blueprints/index.d.ts +0 -20
  57. package/dist/cores/blueprints/index.js +0 -10
  58. package/dist/cores/functions/index.d.ts +0 -16
  59. package/dist/cores/functions/index.js +0 -8
  60. package/dist/utils/display/index.d.ts +0 -5
  61. package/dist/utils/display/index.js +0 -5
  62. package/dist/utils/index.d.ts +0 -8
  63. package/dist/utils/index.js +0 -8
package/dist/index.js CHANGED
@@ -1,21 +1 @@
1
1
  export { run } from '@oclif/core';
2
- // Blueprints command classes
3
- export { default as BlueprintsAddCommand } from './commands/blueprints/add.js';
4
- export { default as BlueprintsConfigCommand } from './commands/blueprints/config.js';
5
- export { default as BlueprintsDeployCommand } from './commands/blueprints/deploy.js';
6
- export { default as BlueprintsDestroyCommand } from './commands/blueprints/destroy.js';
7
- export { default as BlueprintsDoctorCommand } from './commands/blueprints/doctor.js';
8
- export { default as BlueprintsInfoCommand } from './commands/blueprints/info.js';
9
- export { default as BlueprintsInitCommand } from './commands/blueprints/init.js';
10
- export { default as BlueprintsLogsCommand } from './commands/blueprints/logs.js';
11
- export { default as BlueprintsPlanCommand } from './commands/blueprints/plan.js';
12
- export { default as BlueprintsPromoteCommand } from './commands/blueprints/promote.js';
13
- export { default as BlueprintsStacksCommand } from './commands/blueprints/stacks.js';
14
- // Functions command classes
15
- export { default as FunctionsAddCommand } from './commands/functions/add.js';
16
- export { default as FunctionsDevCommand } from './commands/functions/dev.js';
17
- export { default as FunctionsEnvAddCommand } from './commands/functions/env/add.js';
18
- export { default as FunctionsEnvListCommand } from './commands/functions/env/list.js';
19
- export { default as FunctionsEnvRemoveCommand } from './commands/functions/env/remove.js';
20
- export { default as FunctionsLogsCommand } from './commands/functions/logs.js';
21
- export { default as FunctionsTestCommand } from './commands/functions/test.js';
@@ -0,0 +1,14 @@
1
+ interface CopyTool {
2
+ cmd: string;
3
+ args: string[];
4
+ }
5
+ export declare class ClipboardUnavailableError extends Error {
6
+ code: string;
7
+ triedTools: string[];
8
+ constructor(message: string, triedTools?: string[]);
9
+ }
10
+ /** Candidate copy tools for the current OS, in fallback order. */
11
+ export declare function pickCopyTools(): CopyTool[];
12
+ /** @throws {ClipboardUnavailableError} when no usable clipboard tool is available. */
13
+ export declare function write(text: string): Promise<void>;
14
+ export {};
@@ -0,0 +1,73 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { env, platform } from 'node:process';
3
+ export class ClipboardUnavailableError extends Error {
4
+ code = 'CLIPBOARD_UNAVAILABLE';
5
+ triedTools;
6
+ constructor(message, triedTools = []) {
7
+ super(message);
8
+ this.name = 'ClipboardUnavailableError';
9
+ this.triedTools = triedTools;
10
+ }
11
+ }
12
+ /** Candidate copy tools for the current OS, in fallback order. */
13
+ export function pickCopyTools() {
14
+ if (platform === 'darwin')
15
+ return [{ cmd: 'pbcopy', args: [] }];
16
+ if (platform === 'win32')
17
+ return [{ cmd: 'clip', args: [] }];
18
+ const tools = [];
19
+ if (env.WAYLAND_DISPLAY)
20
+ tools.push({ cmd: 'wl-copy', args: [] });
21
+ if (env.DISPLAY) {
22
+ tools.push({ cmd: 'xclip', args: ['-selection', 'clipboard'] });
23
+ tools.push({ cmd: 'xsel', args: ['--clipboard', '--input'] });
24
+ }
25
+ if (env.WSL_DISTRO_NAME)
26
+ tools.push({ cmd: 'clip.exe', args: [] });
27
+ return tools;
28
+ }
29
+ function tryWrite(tool, text) {
30
+ return new Promise((resolve, reject) => {
31
+ let child;
32
+ try {
33
+ child = spawn(tool.cmd, tool.args, { stdio: ['pipe', 'ignore', 'pipe'] });
34
+ }
35
+ catch (err) {
36
+ reject(err instanceof Error ? err : new Error(String(err)));
37
+ return;
38
+ }
39
+ let stderr = '';
40
+ child.on('error', reject);
41
+ child.stderr?.on('data', (chunk) => {
42
+ stderr += chunk.toString();
43
+ });
44
+ child.on('close', (code) => {
45
+ if (code === 0) {
46
+ resolve();
47
+ return;
48
+ }
49
+ reject(new Error(`${tool.cmd} exited with code ${code}: ${stderr.trim()}`));
50
+ });
51
+ child.stdin?.end(text, 'utf8');
52
+ });
53
+ }
54
+ /** @throws {ClipboardUnavailableError} when no usable clipboard tool is available. */
55
+ export async function write(text) {
56
+ const tools = pickCopyTools();
57
+ if (tools.length === 0) {
58
+ throw new ClipboardUnavailableError(`No clipboard tool available for platform "${platform}".`, []);
59
+ }
60
+ const triedTools = tools.map((t) => t.cmd);
61
+ let lastErr;
62
+ for (const tool of tools) {
63
+ try {
64
+ await tryWrite(tool, text);
65
+ return;
66
+ }
67
+ catch (err) {
68
+ lastErr = err;
69
+ }
70
+ }
71
+ const message = lastErr instanceof Error ? lastErr.message : String(lastErr);
72
+ throw new ClipboardUnavailableError(`Clipboard write failed. Tools tried: ${triedTools.join(', ')}. Last error: ${message}`, triedTools);
73
+ }
@@ -1,4 +1,8 @@
1
- import type { BlueprintIssue } from '../../actions/blueprints/index.js';
2
1
  import type { BlueprintParserError } from '../types.js';
2
+ export type BlueprintIssue = {
3
+ code: 'NO_STACK_ID' | 'NO_SCOPE_TYPE' | 'NO_SCOPE_ID' | 'NO_STACK' | 'PARSE_ERROR';
4
+ message: string;
5
+ errors?: BlueprintParserError[];
6
+ };
3
7
  export declare function presentBlueprintIssues(issues: BlueprintIssue[]): string;
4
8
  export declare function presentBlueprintParserErrors(errors: BlueprintParserError[]): string;
@@ -1,33 +1,73 @@
1
+ import type { Project } from '../../actions/sanity/projects.js';
1
2
  import type { Logger } from '../logger.js';
3
+ import type { ScopeType } from '../types.js';
4
+ export declare const BACK: unique symbol;
5
+ export type Back = typeof BACK;
2
6
  export declare function promptForBlueprintType(): Promise<string>;
3
7
  /**
4
- * Prompt the user for a Project after selecting an Organization.
5
- * @param token - The Sanity API token
6
- * @returns The selected project, with the projectId and displayName
7
- * @throws {Error} If the user does not have any projects or if the API call fails
8
+ * Fetches organizations and prompts the user to pick one (if multiple).
9
+ * Returns the selected organization along with its projects.
8
10
  */
9
- export declare function promptForProject({ token, knownOrganizationId, knownProjectId, logger, }: {
11
+ export declare function promptForOrganization({ token, knownOrganizationId, logger, allowBack, }: {
10
12
  token: string;
11
13
  knownOrganizationId?: string;
12
- knownProjectId?: string;
13
14
  logger: Logger;
15
+ allowBack?: boolean;
16
+ }): Promise<{
17
+ organization: {
18
+ id: string;
19
+ name: string;
20
+ };
21
+ projects: Project[];
22
+ wasPrompted: boolean;
23
+ } | Back>;
24
+ /**
25
+ * Asks the user whether to scope a Blueprint to an organization or a project.
26
+ */
27
+ export declare function promptForScope({ allowBack, hasProjects, }?: {
28
+ allowBack?: boolean;
29
+ hasProjects?: boolean;
30
+ }): Promise<ScopeType | Back>;
31
+ /**
32
+ * Prompts the user to pick a project from a pre-fetched list of projects.
33
+ * Use after promptForOrganization when the user chose project scope.
34
+ */
35
+ export declare function promptForProjectInOrg({ projects, knownProjectId, logger, allowBack, }: {
36
+ projects: Project[];
37
+ knownProjectId?: string;
38
+ logger?: Logger;
39
+ allowBack?: boolean;
14
40
  }): Promise<{
15
41
  projectId: string;
16
42
  displayName: string;
17
- }>;
43
+ } | Back>;
18
44
  /**
19
- * Prompt the user for a Stack ID after selecting a Project.
20
- * Can be used to create a new Stack or select an existing one.
21
- * @param projectId - The ID of the Project
22
- * @param token - The Sanity API token
23
- * @returns The selected Stack ID
24
- * @throws {Error} If the user does not have any Stacks or if the API call fails
45
+ * Prompt the user for a Stack after scope has been determined.
46
+ * Can create a new Stack or select an existing one.
25
47
  */
26
- export declare function promptForStack({ projectId, token, logger, }: {
27
- projectId: string;
48
+ export declare function promptForStack({ scopeType, scopeId, token, logger, allowBack, }: {
49
+ scopeType: ScopeType;
50
+ scopeId: string;
28
51
  token: string;
29
52
  logger: Logger;
53
+ allowBack?: boolean;
30
54
  }): Promise<{
31
55
  stackId: string;
32
56
  name: string;
57
+ } | Back>;
58
+ /**
59
+ * Full wizard: Organization -> Scope -> Project (if project-scoped) -> Stack.
60
+ * Supports back-navigation between steps.
61
+ */
62
+ export declare function runScopeAndStackWizard({ token, knownOrganizationId, knownProjectId, logger, }: {
63
+ token: string;
64
+ knownOrganizationId?: string;
65
+ knownProjectId?: string;
66
+ logger: Logger;
67
+ }): Promise<{
68
+ scopeType: ScopeType;
69
+ scopeId: string;
70
+ displayName: string;
71
+ stackId: string;
72
+ stackName: string;
33
73
  }>;
@@ -3,6 +3,60 @@ import { createEmptyStack, listStacks } from '../../actions/blueprints/stacks.js
3
3
  import { groupProjectsByOrganization } from '../../actions/sanity/projects.js';
4
4
  import { styleText } from '../style-text.js';
5
5
  import { niceId } from './presenters.js';
6
+ // --- Wizard infrastructure ---
7
+ export const BACK = Symbol('back');
8
+ /**
9
+ * Runs a sequence of prompt steps with back-navigation support.
10
+ * Each step can return BACK to go to the previous non-skipped step.
11
+ * When going back, stale results from the current step onward are cleared.
12
+ */
13
+ async function runWizard(steps) {
14
+ const results = {};
15
+ let i = 0;
16
+ while (i < steps.length) {
17
+ const step = steps[i];
18
+ if (step.shouldSkip?.(results)) {
19
+ i++;
20
+ continue;
21
+ }
22
+ const answer = await step.prompt(results);
23
+ if (answer === BACK) {
24
+ if (i === 0) {
25
+ // Cannot go back from the first step; re-prompt it
26
+ continue;
27
+ }
28
+ // Walk back to the previous non-skipped step
29
+ do {
30
+ i--;
31
+ } while (i > 0 && steps[i].shouldSkip?.(results));
32
+ // Erase 2 lines: the "← Back" answer line and the previous step's
33
+ // answered prompt. Back is only offered on steps where the previous
34
+ // step rendered a prompt, so this is always correct.
35
+ process.stdout.write('\x1b[1A\x1b[2K\x1b[1A\x1b[2K');
36
+ // Clear stale results from this step forward
37
+ for (let j = i; j < steps.length; j++) {
38
+ delete results[steps[j].key];
39
+ }
40
+ continue;
41
+ }
42
+ results[step.key] = answer;
43
+ i++;
44
+ }
45
+ return results;
46
+ }
47
+ /**
48
+ * Appends a "Back" choice to a list of select choices.
49
+ * Skipped for the first step in a wizard (nowhere to go back to).
50
+ */
51
+ function withBackChoice(choices, allowBack) {
52
+ if (!allowBack)
53
+ return choices;
54
+ return [
55
+ ...choices,
56
+ { name: styleText('dim', '\u2190 Back'), value: BACK, description: 'Return to previous step' },
57
+ ];
58
+ }
59
+ // --- Standalone prompts ---
6
60
  export async function promptForBlueprintType() {
7
61
  return await select({
8
62
  message: 'Choose a Blueprint file type:',
@@ -14,60 +68,117 @@ export async function promptForBlueprintType() {
14
68
  default: 'ts',
15
69
  });
16
70
  }
71
+ // --- Composable prompt functions for scope selection ---
17
72
  /**
18
- * Prompt the user for a Project after selecting an Organization.
19
- * @param token - The Sanity API token
20
- * @returns The selected project, with the projectId and displayName
21
- * @throws {Error} If the user does not have any projects or if the API call fails
73
+ * Fetches organizations and prompts the user to pick one (if multiple).
74
+ * Returns the selected organization along with its projects.
22
75
  */
23
- export async function promptForProject({ token, knownOrganizationId, knownProjectId, logger, }) {
76
+ export async function promptForOrganization({ token, knownOrganizationId, logger, allowBack = false, }) {
77
+ const spinner = logger.ora('Loading organizations...').start();
24
78
  const { ok, error, organizations } = await groupProjectsByOrganization({ token, logger });
79
+ spinner.stop();
25
80
  if (!ok) {
26
- throw new Error(error ?? 'Unknown error listing projects');
81
+ throw new Error(error ?? 'Unknown error listing organizations');
27
82
  }
28
83
  if (organizations.length === 0) {
29
- throw new Error('No Sanity projects found. Use `npx sanity init` to create one.');
84
+ throw new Error('No Sanity organizations found. Use `npx sanity init` to create a project.');
30
85
  }
31
- let projects;
86
+ let picked;
87
+ let wasPrompted = false;
32
88
  if (organizations.length > 1) {
33
- const orgChoices = organizations.map(({ organization, projects }) => ({
34
- name: `"${organization.name}" ${niceId(organization.id)}`,
35
- value: organization.id,
36
- disabled: !projects || projects.length === 0 ? '(0 Projects)' : false,
37
- }));
89
+ wasPrompted = true;
90
+ const orgChoices = organizations.map(({ organization, projects }) => {
91
+ const projectCount = projects?.length ?? 0;
92
+ return {
93
+ name: `"${organization.name}" ${niceId(organization.id)}`,
94
+ value: organization.id,
95
+ description: `${projectCount} ${projectCount === 1 ? 'project' : 'projects'}`,
96
+ };
97
+ });
38
98
  const pickedOrgId = await select({
39
- message: 'Which Organization would you like to use?',
40
- choices: orgChoices,
99
+ message: 'Which organization would you like to use?',
100
+ choices: withBackChoice(orgChoices, allowBack),
41
101
  default: knownOrganizationId,
42
102
  });
103
+ if (pickedOrgId === BACK)
104
+ return BACK;
43
105
  const pickedOrg = organizations.find((o) => o.organization.id === pickedOrgId);
44
- projects = pickedOrg?.projects;
106
+ if (!pickedOrg) {
107
+ throw new Error('No organization found with the given ID');
108
+ }
109
+ picked = pickedOrg;
45
110
  }
46
111
  else {
47
- projects = organizations[0].projects;
112
+ picked = organizations[0];
113
+ logger(`Using organization "${picked.organization.name}" ${niceId(picked.organization.id)}`);
114
+ }
115
+ return {
116
+ organization: picked.organization,
117
+ projects: picked.projects ?? [],
118
+ wasPrompted,
119
+ };
120
+ }
121
+ /**
122
+ * Asks the user whether to scope a Blueprint to an organization or a project.
123
+ */
124
+ export async function promptForScope({ allowBack = false, hasProjects = true, } = {}) {
125
+ const choices = [
126
+ {
127
+ name: 'Organization',
128
+ value: 'organization',
129
+ description: 'Manage resources across all projects in the organization',
130
+ },
131
+ {
132
+ name: 'Project',
133
+ value: 'project',
134
+ description: 'Manage resources within a single project',
135
+ disabled: hasProjects ? false : '(no projects in this organization)',
136
+ },
137
+ ];
138
+ const answer = await select({
139
+ message: 'How would you like to scope your Blueprint?',
140
+ choices: withBackChoice(choices, allowBack),
141
+ default: 'organization',
142
+ });
143
+ return answer;
144
+ }
145
+ /**
146
+ * Prompts the user to pick a project from a pre-fetched list of projects.
147
+ * Use after promptForOrganization when the user chose project scope.
148
+ */
149
+ export async function promptForProjectInOrg({ projects, knownProjectId, logger, allowBack = false, }) {
150
+ if (projects.length === 0) {
151
+ throw new Error('No projects found in this organization.');
48
152
  }
49
153
  const projectChoices = projects.map(({ displayName, id: projectId }) => ({
50
154
  name: `"${displayName}" ${niceId(projectId)}`,
51
155
  value: projectId,
52
156
  }));
53
157
  let pickedProjectId;
54
- if (projectChoices.length === 1) {
158
+ if (projectChoices.length === 1 && !allowBack) {
159
+ // Single-project confirm only when not in wizard (wizard has Back for navigation)
55
160
  const onlyProject = projectChoices[0];
56
161
  const confirmed = await confirm({
57
- message: `Found 1 project. Use ${onlyProject.name}?`,
162
+ message: `Found 1 project in this organization. Use ${onlyProject.name}?`,
58
163
  default: true,
59
164
  });
60
- if (confirmed)
165
+ if (confirmed) {
61
166
  pickedProjectId = onlyProject.value;
62
- else
167
+ logger?.(`Using project ${onlyProject.name}`);
168
+ }
169
+ else {
63
170
  throw new Error('No project selected');
171
+ }
64
172
  }
65
173
  else {
66
- pickedProjectId = await select({
67
- message: 'Choose a Sanity Project:',
68
- choices: projectChoices,
174
+ const answer = await select({
175
+ message: 'Choose a Sanity project:',
176
+ choices: withBackChoice(projectChoices, allowBack),
69
177
  default: knownProjectId,
70
178
  });
179
+ if (answer === BACK)
180
+ return BACK;
181
+ pickedProjectId = answer;
71
182
  }
72
183
  const pickedProject = projects.find((p) => p.id === pickedProjectId);
73
184
  if (!pickedProject)
@@ -75,44 +186,69 @@ export async function promptForProject({ token, knownOrganizationId, knownProjec
75
186
  return { projectId: pickedProject.id, displayName: pickedProject.displayName };
76
187
  }
77
188
  /**
78
- * Prompt the user for a Stack ID after selecting a Project.
79
- * Can be used to create a new Stack or select an existing one.
80
- * @param projectId - The ID of the Project
81
- * @param token - The Sanity API token
82
- * @returns The selected Stack ID
83
- * @throws {Error} If the user does not have any Stacks or if the API call fails
189
+ * Prompt the user for a Stack after scope has been determined.
190
+ * Can create a new Stack or select an existing one.
84
191
  */
85
- export async function promptForStack({ projectId, token, logger, }) {
86
- const { ok: stacksOk, error: stacksErr, stacks, } = await listStacks({ token, scopeType: 'project', scopeId: projectId }, logger);
192
+ export async function promptForStack({ scopeType, scopeId, token, logger, allowBack = false, }) {
193
+ if (!scopeId) {
194
+ throw new Error(`Cannot list Stacks: no ${scopeType} ID provided. ` +
195
+ 'Provide --project-id or --organization-id, or select a scope in the wizard.');
196
+ }
197
+ const spinner = logger.ora('Loading Stacks...').start();
198
+ const { ok: stacksOk, error: stacksErr, stacks, } = await listStacks({ token, scopeType, scopeId }, logger);
199
+ spinner.stop();
87
200
  if (!stacksOk) {
88
- throw new Error(stacksErr || 'Failed to list Stacks');
201
+ const scopeLabel = scopeType === 'organization' ? 'organization' : 'project';
202
+ throw new Error(stacksErr || `Failed to list Stacks for ${scopeLabel} "${scopeId}".`);
89
203
  }
90
204
  const NEW_STACK_ID = 'new';
91
205
  let pickedStackId = NEW_STACK_ID;
206
+ if (stacks.length === 0) {
207
+ logger('No existing Stacks found. Creating a new one.');
208
+ }
92
209
  if (stacks.length > 0) {
93
- const stackChoices = [];
94
- stackChoices.push(new Separator(styleText('underline', 'Create a new Stack:')));
95
- stackChoices.push({ name: styleText('bold', 'New Stack'), value: NEW_STACK_ID });
96
- stackChoices.push(new Separator(styleText('underline', 'Use an existing Stack:')));
97
- stackChoices.push(...stacks.map((s) => ({
98
- name: `"${s.name}" ${niceId(s.id)} ${styleText('dim', `(${s.resourceCount} ${s.resourceCount === 1 ? 'resource' : 'resources'})`)}`,
99
- value: s.id,
100
- })));
101
- pickedStackId = await select({
210
+ const stackChoices = [
211
+ new Separator(styleText('underline', 'Create a new Stack:')),
212
+ { name: styleText('bold', 'New Stack'), value: NEW_STACK_ID },
213
+ new Separator(styleText('underline', 'Use an existing Stack:')),
214
+ ...stacks.map((s) => ({
215
+ name: `"${s.name}" ${niceId(s.id)} ${styleText('dim', `(${s.resourceCount} ${s.resourceCount === 1 ? 'resource' : 'resources'})`)}`,
216
+ value: s.id,
217
+ })),
218
+ ];
219
+ if (allowBack) {
220
+ stackChoices.push(new Separator(), {
221
+ name: styleText('dim', '\u2190 Back'),
222
+ value: BACK,
223
+ description: 'Return to previous step',
224
+ });
225
+ }
226
+ const answer = await select({
102
227
  message: 'Select a deployment Stack:',
103
228
  choices: stackChoices,
104
229
  default: NEW_STACK_ID,
105
230
  });
231
+ if (answer === BACK)
232
+ return BACK;
233
+ pickedStackId = answer;
234
+ }
235
+ else if (allowBack) {
236
+ const proceed = await select({
237
+ message: 'No existing Stacks. Create a new one?',
238
+ choices: withBackChoice([{ name: 'Yes, create a new Stack', value: 'yes' }], allowBack),
239
+ });
240
+ if (proceed === BACK)
241
+ return BACK;
106
242
  }
107
243
  if (pickedStackId === NEW_STACK_ID) {
108
244
  const stackName = await input({
109
245
  message: 'Enter a name for your new Stack:',
110
- validate: (input) => input.length > 0 || 'Stack name is required',
246
+ validate: (val) => val.length > 0 || 'Stack name is required',
111
247
  });
112
248
  const stack = await createEmptyStack({
113
249
  token,
114
- scopeType: 'project',
115
- scopeId: projectId,
250
+ scopeType,
251
+ scopeId,
116
252
  name: stackName,
117
253
  logger,
118
254
  });
@@ -123,3 +259,93 @@ export async function promptForStack({ projectId, token, logger, }) {
123
259
  throw new Error('Stack not found');
124
260
  return { stackId: pickedStack.id, name: pickedStack.name };
125
261
  }
262
+ /**
263
+ * Full wizard: Organization -> Scope -> Project (if project-scoped) -> Stack.
264
+ * Supports back-navigation between steps.
265
+ */
266
+ export async function runScopeAndStackWizard({ token, knownOrganizationId, knownProjectId, logger, }) {
267
+ const steps = [
268
+ {
269
+ key: 'organization',
270
+ prompt: async () => {
271
+ return await promptForOrganization({
272
+ token,
273
+ knownOrganizationId,
274
+ logger,
275
+ // First step -- no back
276
+ });
277
+ },
278
+ },
279
+ {
280
+ key: 'scopeType',
281
+ prompt: async (results) => {
282
+ const orgResult = results.organization;
283
+ return await promptForScope({
284
+ allowBack: orgResult.wasPrompted,
285
+ hasProjects: orgResult.projects.length > 0,
286
+ });
287
+ },
288
+ },
289
+ {
290
+ key: 'project',
291
+ shouldSkip: (results) => results.scopeType === 'organization',
292
+ prompt: async (results) => {
293
+ const orgResult = results.organization;
294
+ if (!orgResult)
295
+ throw new Error('Organization not resolved');
296
+ return await promptForProjectInOrg({
297
+ projects: orgResult.projects,
298
+ knownProjectId,
299
+ logger,
300
+ allowBack: true,
301
+ });
302
+ },
303
+ },
304
+ {
305
+ key: 'stack',
306
+ prompt: async (results) => {
307
+ const scopeType = results.scopeType;
308
+ if (!scopeType)
309
+ throw new Error('Scope type not resolved');
310
+ let scopeId;
311
+ if (scopeType === 'organization') {
312
+ const orgResult = results.organization;
313
+ scopeId = orgResult.organization.id;
314
+ }
315
+ else {
316
+ const project = results.project;
317
+ scopeId = project.projectId;
318
+ }
319
+ return await promptForStack({
320
+ scopeType,
321
+ scopeId,
322
+ token,
323
+ logger,
324
+ allowBack: true,
325
+ });
326
+ },
327
+ },
328
+ ];
329
+ const result = await runWizard(steps);
330
+ const orgResult = result.organization;
331
+ const scopeType = result.scopeType;
332
+ const stack = result.stack;
333
+ let scopeId;
334
+ let displayName;
335
+ if (scopeType === 'organization') {
336
+ scopeId = orgResult.organization.id;
337
+ displayName = orgResult.organization.name;
338
+ }
339
+ else {
340
+ const project = result.project;
341
+ scopeId = project.projectId;
342
+ displayName = project.displayName;
343
+ }
344
+ return {
345
+ scopeType,
346
+ scopeId,
347
+ displayName,
348
+ stackId: stack.stackId,
349
+ stackName: stack.name,
350
+ };
351
+ }