Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/commands/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,33 @@ describe('runUpdate', () => {
expect(result.updatedFields).toContain('name');
});

it('P7 — dry-run with --password-file does not read the filesystem', async () => {
const { credentialsPath } = makeCreds();
const fetchImpl = vi.fn(async () => {
throw new Error('should not hit network');
});
const result = await runUpdate(
{
profile: 'default',
output: 'json',
debug: false,
dryRun: true,
projectId: 'proj_dry',
passwordFile: '/tmp/definitely-not-here-testsprite',
},
{
credentialsPath,
fetchImpl: fetchImpl as unknown as typeof fetch,
stdout: () => {},
stderr: () => {},
},
);

expect(fetchImpl).not.toHaveBeenCalled();
expect(result.id).toBe('proj_dry');
expect(result.updatedFields).toContain('password');
});

it('P7 — renders text mode with updatedFields and updatedAt', async () => {
const { credentialsPath } = makeCreds();
const updateResponse: CliUpdateProjectResponse = {
Expand Down
48 changes: 31 additions & 17 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,27 +254,24 @@ export async function runUpdate(
throw localValidationError('--description must be at most 2000 characters');
}

// Resolve password
let password = opts.password;
if (password === undefined && opts.passwordFile !== undefined) {
password = readFileSync(opts.passwordFile, 'utf8').trim();
}

// P2-7: guard --url against localhost/RFC1918/non-http(s).
if (opts.targetUrl !== undefined) {
assertNotLocal(opts.targetUrl);
}

const mutableFields: Record<string, string | undefined> = {
name: opts.name,
targetUrl: opts.targetUrl,
username: opts.username,
password,
description: opts.description,
instruction: opts.instruction,
const passwordSupplied = opts.password !== undefined || opts.passwordFile !== undefined;
const mutableFields: Record<string, boolean> = {
name: opts.name !== undefined,
targetUrl: opts.targetUrl !== undefined,
username: opts.username !== undefined,
password: passwordSupplied,
description: opts.description !== undefined,
instruction: opts.instruction !== undefined,
};
const presentFields = Object.entries(mutableFields).filter(([, v]) => v !== undefined);
if (presentFields.length === 0) {
const presentFieldNames = Object.entries(mutableFields)
.filter(([, present]) => present)
.map(([field]) => field);
if (presentFieldNames.length === 0) {
throw localValidationError(
'At least one mutable flag is required: --name, --url, --username, --password/--password-file, --description, or --instruction.',
);
Expand All @@ -290,19 +287,36 @@ export async function runUpdate(
}
const sample: CliUpdateProjectResponse = {
id: opts.projectId,
updatedFields: presentFields.map(([k]) => k),
updatedFields: presentFieldNames,
updatedAt: '2026-05-16T00:00:00.000Z',
};
out.print(sample, data => renderUpdateText(data as CliUpdateProjectResponse));
return sample;
}

// Resolve password only on the real path. Dry-run must not touch the
// filesystem, even when --password-file is present.
let password = opts.password;
if (password === undefined && opts.passwordFile !== undefined) {
password = readFileSync(opts.passwordFile, 'utf8').trim();
}

const idempotencyKey = opts.idempotencyKey ?? `cli-proj-update-${randomUUID()}`;
if (opts.idempotencyKey === undefined && (opts.output === 'json' || opts.verbose || opts.debug)) {
stderr(`idempotency-key: ${idempotencyKey}`);
}

const body = Object.fromEntries(presentFields) as Record<string, string>;
const bodyFields: Record<string, string | undefined> = {
name: opts.name,
targetUrl: opts.targetUrl,
username: opts.username,
password,
description: opts.description,
instruction: opts.instruction,
};
const body = Object.fromEntries(
Object.entries(bodyFields).filter(([, v]) => v !== undefined),
) as Record<string, string>;
const client = makeClient(opts, deps);
const updated = await client.patch<CliUpdateProjectResponse>(
`/projects/${encodeURIComponent(opts.projectId)}`,
Expand Down
16 changes: 16 additions & 0 deletions test/cli.subprocess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,22 @@ describe('--dry-run subprocess smoke', () => {
expect(parsed.id).toBeTruthy();
}, 30_000);

it('project update --dry-run does not read a missing --password-file', async () => {
const result = await runCli([
'project',
'update',
'proj_anything',
'--password-file',
'/tmp/definitely-not-here-testsprite',
'--dry-run',
'--output',
'json',
]);
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout) as { updatedFields: string[] };
expect(parsed.updatedFields).toContain('password');
}, 30_000);

it('test list --dry-run returns canned TestList', async () => {
const result = await runCli([
'test',
Expand Down