Skip to content

Commit a314204

Browse files
committed
ods command identifier lookup
1 parent dbb0981 commit a314204

10 files changed

Lines changed: 472 additions & 15 deletions

File tree

packages/b2c-cli/src/commands/ods/delete.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async function confirm(message: string): Promise<boolean> {
3232
export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
3333
static args = {
3434
sandboxId: Args.string({
35-
description: 'Sandbox ID (UUID)',
35+
description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)',
3636
required: true,
3737
}),
3838
};
@@ -44,7 +44,8 @@ export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
4444

4545
static examples = [
4646
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789',
47-
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --force',
47+
'<%= config.bin %> <%= command.id %> zzzv-123',
48+
'<%= config.bin %> <%= command.id %> zzzv_123 --force',
4849
];
4950

5051
static flags = {
@@ -56,7 +57,7 @@ export default class OdsDelete extends OdsCommand<typeof OdsDelete> {
5657
};
5758

5859
async run(): Promise<void> {
59-
const sandboxId = this.args.sandboxId;
60+
const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
6061

6162
// Get sandbox details first to show in confirmation
6263
const getResult = await this.odsClient.GET('/sandboxes/{sandboxId}', {

packages/b2c-cli/src/commands/ods/get.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type SandboxModel = OdsComponents['schemas']['SandboxModel'];
1717
export default class OdsGet extends OdsCommand<typeof OdsGet> {
1818
static args = {
1919
sandboxId: Args.string({
20-
description: 'Sandbox ID (UUID)',
20+
description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)',
2121
required: true,
2222
}),
2323
};
@@ -31,11 +31,12 @@ export default class OdsGet extends OdsCommand<typeof OdsGet> {
3131

3232
static examples = [
3333
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789',
34-
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --json',
34+
'<%= config.bin %> <%= command.id %> zzzv-123',
35+
'<%= config.bin %> <%= command.id %> zzzv_123 --json',
3536
];
3637

3738
async run(): Promise<SandboxModel> {
38-
const sandboxId = this.args.sandboxId;
39+
const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
3940

4041
this.log(t('commands.ods.get.fetching', 'Fetching sandbox {{sandboxId}}...', {sandboxId}));
4142

packages/b2c-cli/src/commands/ods/restart.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel'];
1616
export default class OdsRestart extends OdsCommand<typeof OdsRestart> {
1717
static args = {
1818
sandboxId: Args.string({
19-
description: 'Sandbox ID (UUID)',
19+
description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)',
2020
required: true,
2121
}),
2222
};
@@ -30,11 +30,12 @@ export default class OdsRestart extends OdsCommand<typeof OdsRestart> {
3030

3131
static examples = [
3232
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789',
33-
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --json',
33+
'<%= config.bin %> <%= command.id %> zzzv-123',
34+
'<%= config.bin %> <%= command.id %> zzzv_123 --json',
3435
];
3536

3637
async run(): Promise<SandboxOperationModel> {
37-
const sandboxId = this.args.sandboxId;
38+
const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
3839

3940
this.log(t('commands.ods.restart.restarting', 'Restarting sandbox {{sandboxId}}...', {sandboxId}));
4041

packages/b2c-cli/src/commands/ods/start.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel'];
1616
export default class OdsStart extends OdsCommand<typeof OdsStart> {
1717
static args = {
1818
sandboxId: Args.string({
19-
description: 'Sandbox ID (UUID)',
19+
description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)',
2020
required: true,
2121
}),
2222
};
@@ -30,11 +30,12 @@ export default class OdsStart extends OdsCommand<typeof OdsStart> {
3030

3131
static examples = [
3232
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789',
33-
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --json',
33+
'<%= config.bin %> <%= command.id %> zzzv-123',
34+
'<%= config.bin %> <%= command.id %> zzzv_123 --json',
3435
];
3536

3637
async run(): Promise<SandboxOperationModel> {
37-
const sandboxId = this.args.sandboxId;
38+
const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
3839

3940
this.log(t('commands.ods.start.starting', 'Starting sandbox {{sandboxId}}...', {sandboxId}));
4041

packages/b2c-cli/src/commands/ods/stop.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel'];
1616
export default class OdsStop extends OdsCommand<typeof OdsStop> {
1717
static args = {
1818
sandboxId: Args.string({
19-
description: 'Sandbox ID (UUID)',
19+
description: 'Sandbox ID (UUID or realm-instance, e.g., abcd-123)',
2020
required: true,
2121
}),
2222
};
@@ -30,11 +30,12 @@ export default class OdsStop extends OdsCommand<typeof OdsStop> {
3030

3131
static examples = [
3232
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789',
33-
'<%= config.bin %> <%= command.id %> abc12345-1234-1234-1234-abc123456789 --json',
33+
'<%= config.bin %> <%= command.id %> zzzv-123',
34+
'<%= config.bin %> <%= command.id %> zzzv_123 --json',
3435
];
3536

3637
async run(): Promise<SandboxOperationModel> {
37-
const sandboxId = this.args.sandboxId;
38+
const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
3839

3940
this.log(t('commands.ods.stop.stopping', 'Stopping sandbox {{sandboxId}}...', {sandboxId}));
4041

packages/b2c-tooling-sdk/src/cli/ods-command.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Command, Flags} from '@oclif/core';
77
import {OAuthCommand} from './oauth-command.js';
88
import {createOdsClient, type OdsClient} from '../clients/ods.js';
99
import {DEFAULT_ODS_HOST} from '../defaults.js';
10+
import {resolveSandboxId, SandboxNotFoundError} from '../operations/ods/sandbox-lookup.js';
1011

1112
/**
1213
* Base command for ODS (On-Demand Sandbox) operations.
@@ -82,4 +83,32 @@ export abstract class OdsCommand<T extends typeof Command> extends OAuthCommand<
8283
protected get odsHost(): string {
8384
return this.flags['sandbox-api-host'] ?? DEFAULT_ODS_HOST;
8485
}
86+
87+
/**
88+
* Resolves a sandbox identifier to a UUID.
89+
*
90+
* Supports both UUID format and friendly format (realm-instance, e.g., "abcd-123" or "abcd_123").
91+
* If given a UUID, returns it directly. If given a friendly format, queries the API to find
92+
* the matching sandbox.
93+
*
94+
* @param identifier - Sandbox identifier (UUID or friendly format)
95+
* @returns The sandbox UUID
96+
* @throws Error if the sandbox cannot be found (friendly ID not resolved)
97+
*
98+
* @example
99+
* ```typescript
100+
* // In a command's run() method:
101+
* const sandboxId = await this.resolveSandboxId(this.args.sandboxId);
102+
* ```
103+
*/
104+
protected async resolveSandboxId(identifier: string): Promise<string> {
105+
try {
106+
return await resolveSandboxId(this.odsClient, identifier);
107+
} catch (error) {
108+
if (error instanceof SandboxNotFoundError) {
109+
this.error(error.message);
110+
}
111+
throw error;
112+
}
113+
}
85114
}

packages/b2c-tooling-sdk/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,15 @@ export type {
204204
DownloadDocsResult,
205205
} from './operations/docs/index.js';
206206

207+
// Operations - ODS
208+
export {
209+
isUuid,
210+
isFriendlySandboxId,
211+
parseFriendlySandboxId,
212+
resolveSandboxId,
213+
SandboxNotFoundError,
214+
} from './operations/ods/index.js';
215+
207216
// Defaults
208217
export {DEFAULT_ACCOUNT_MANAGER_HOST, DEFAULT_ODS_HOST} from './defaults.js';
209218

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
/**
7+
* ODS (On-Demand Sandbox) operations.
8+
*
9+
* @module operations/ods
10+
*/
11+
12+
export {
13+
isUuid,
14+
isFriendlySandboxId,
15+
parseFriendlySandboxId,
16+
resolveSandboxId,
17+
SandboxNotFoundError,
18+
} from './sandbox-lookup.js';
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
/**
7+
* Sandbox ID lookup utilities for resolving friendly sandbox identifiers.
8+
*
9+
* @module operations/ods/sandbox-lookup
10+
*/
11+
import type {OdsClient} from '../../clients/ods.js';
12+
13+
/**
14+
* UUID regex pattern (standard 8-4-4-4-12 format).
15+
*/
16+
const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
17+
18+
/**
19+
* Friendly sandbox ID pattern: realm-instance or realm_instance
20+
* - realm: 4 alphanumeric characters
21+
* - separator: dash or underscore
22+
* - instance: 1+ alphanumeric characters
23+
*/
24+
const FRIENDLY_ID_REGEX = /^([a-z\d]{4})[-_]([a-z\d]+)$/i;
25+
26+
/**
27+
* Error thrown when a sandbox cannot be found by its friendly identifier.
28+
*/
29+
export class SandboxNotFoundError extends Error {
30+
constructor(
31+
public readonly identifier: string,
32+
public readonly realm?: string,
33+
public readonly instance?: string,
34+
) {
35+
const message =
36+
realm && instance
37+
? `Sandbox not found: ${identifier} (realm=${realm}, instance=${instance})`
38+
: `Sandbox not found: ${identifier}`;
39+
super(message);
40+
this.name = 'SandboxNotFoundError';
41+
}
42+
}
43+
44+
/**
45+
* Checks if a string is a valid UUID.
46+
*
47+
* @param value - The string to check
48+
* @returns true if the value is a valid UUID
49+
*/
50+
export function isUuid(value: string): boolean {
51+
return UUID_REGEX.test(value);
52+
}
53+
54+
/**
55+
* Checks if a string matches the friendly sandbox ID format (realm-instance or realm_instance).
56+
*
57+
* @param value - The string to check
58+
* @returns true if the value matches the friendly format
59+
*/
60+
export function isFriendlySandboxId(value: string): boolean {
61+
return FRIENDLY_ID_REGEX.test(value);
62+
}
63+
64+
/**
65+
* Parses a friendly sandbox ID into its realm and instance components.
66+
*
67+
* @param value - The friendly ID to parse (e.g., "abcd-123" or "abcd_123")
68+
* @returns Object with realm and instance, or null if not a valid friendly ID
69+
*/
70+
export function parseFriendlySandboxId(value: string): {realm: string; instance: string} | null {
71+
const match = value.match(FRIENDLY_ID_REGEX);
72+
if (!match) {
73+
return null;
74+
}
75+
return {
76+
realm: match[1].toLowerCase(),
77+
instance: match[2].toLowerCase(),
78+
};
79+
}
80+
81+
/**
82+
* Resolves a sandbox identifier to a UUID.
83+
*
84+
* If the identifier is already a UUID, it is returned directly without making an API call.
85+
* If the identifier is a friendly format (realm-instance), it queries the ODS API to find
86+
* the matching sandbox and returns its UUID.
87+
*
88+
* @param client - The ODS API client
89+
* @param identifier - Sandbox identifier (UUID or friendly format like "abcd-123")
90+
* @returns The sandbox UUID
91+
* @throws {SandboxNotFoundError} If the sandbox cannot be found
92+
*
93+
* @example
94+
* ```typescript
95+
* // UUID is returned directly
96+
* const uuid = await resolveSandboxId(client, 'abc12345-1234-1234-1234-abc123456789');
97+
* // => 'abc12345-1234-1234-1234-abc123456789'
98+
*
99+
* // Friendly ID is looked up
100+
* const uuid = await resolveSandboxId(client, 'zzzv-123');
101+
* // => 'abc12345-1234-1234-1234-abc123456789' (actual UUID from API)
102+
* ```
103+
*/
104+
export async function resolveSandboxId(client: OdsClient, identifier: string): Promise<string> {
105+
// If already a UUID, return directly
106+
if (isUuid(identifier)) {
107+
return identifier;
108+
}
109+
110+
// Try to parse as friendly ID
111+
const parsed = parseFriendlySandboxId(identifier);
112+
if (!parsed) {
113+
// Not a UUID and not a friendly ID - treat as UUID and let API return 404
114+
return identifier;
115+
}
116+
117+
// Query sandboxes filtered by realm
118+
const {data, error} = await client.GET('/sandboxes', {
119+
params: {
120+
query: {
121+
filter_params: `realm=${parsed.realm}`,
122+
},
123+
},
124+
});
125+
126+
if (error || !data?.data) {
127+
throw new SandboxNotFoundError(identifier, parsed.realm, parsed.instance);
128+
}
129+
130+
// Find sandbox with matching instance
131+
const sandbox = data.data.find((s) => s.instance?.toLowerCase() === parsed.instance);
132+
133+
if (!sandbox?.id) {
134+
throw new SandboxNotFoundError(identifier, parsed.realm, parsed.instance);
135+
}
136+
137+
return sandbox.id;
138+
}

0 commit comments

Comments
 (0)