Skip to content

Commit f693537

Browse files
feat(core): say which values came from the project config file
When a command takes its organization or project from .cs-launch.json instead of a flag, it now prints one line before anything else: Using the organization and project UIDs from /path/to/.cs-launch.json. so a user in a linked folder can see where the target came from - most usefully just above the projects:delete question. With one value it reads "Using the project UID from ...". Nothing is printed when every value came from a flag, a prompt or a default. - resolveInputsTraced returns the sources map the resolver already built and discarded; resolveInputs keeps its signature on top of it. - Each config-backed resolution spec declares a configLabel (organization, project); resources.test.ts fails if one is missing, so a future config key cannot drop out of the notice. - configSourceNotice words the line from those labels and the full path of the file actually read (a relative --config is resolved first). - LaunchCommand.init prints it through ux.print, like every other line. Only the path is cyan, and only when stdout is a terminal - green reads as success and yellow as a warning here. No info: prefix. The project-folder integration test now expects the notice as the first line of stdout. AGENTS.md documents the notice and configLabel, and the .talismanrc checksums are refreshed for the touched files, and src/core/resolve.ts gets a checksum entry: Talisman flags its FlagKey / resolutionTable lines as key-shaped, none of which is a secret. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8756979 commit f693537

13 files changed

Lines changed: 247 additions & 17 deletions

‎.talismanrc‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,13 @@ fileignoreconfig:
136136
- "a test passes `var`, `env-file` and `from-stack`"
137137
- "an in-flight secret registry to redact against"
138138
- filename: src/core/project-config.ts
139-
checksum: feec5219c90a2368edccab2c290284b6e7ccee7fb78443c7b4852d7b207119a4
139+
checksum: 1f19da706128ee78e1fe20d8624626038893d908a7061b3f2633e741a77c93b6
140140
- filename: src/core/project-config.test.ts
141-
checksum: ff428e7553ba60bacc1ecf61f2076dedde03b09bd7167cc052b780a783217dc0
141+
checksum: 65c0a613ec77d4dc6727224d4d097de4a02b300339286db692dbaea9379565ae
142+
- filename: src/core/resolve.ts
143+
checksum: 25cb3c5eb789bfd148efb1a990932b99970c45d5777344d34e66cf54207c5256
142144
- filename: src/core/resolve.test.ts
143-
checksum: d745e6efbb9c4595ee7c57a2625a73d687d3e880a346f53de2d7da183971d875
145+
checksum: 1a07140c5f61d2c1cbd1f9746fca2e907fd2467f804c62a0ed6c188b6944b126
144146
- filename: src/git/local-repository.ts
145147
checksum: 7f266d6d6796574721ca463a14914759c91ce25dfa5b333f103258fe2336cfe5
146148
- filename: src/core/layering.test.ts
@@ -152,13 +154,13 @@ fileignoreconfig:
152154
- filename: test/integration/projects-delete.test.ts
153155
checksum: 4e2960b921f69bcb5fc1b4d6089b7ded1b71ed54609d8304bf7ee84041cf7fc2
154156
- filename: src/core/launch-command.ts
155-
checksum: d0feba27c3773df89bd527b9e1ba43233d1152927e2c9c69121e084760ed7d94
157+
checksum: 4bccae32140aa3b4da94346b4bfd3b199a2a7d568f26e28d7dad58b266148038
156158
- filename: src/core/launch-command.test.ts
157-
checksum: 76367d660556bd49cf04e494818425f844bb8e1d8f6bfd2168db5ac101d66abc
159+
checksum: 2cc245f8a4af102eb9edb53184d1af2486c6bbf9588a41e56452895a4c580e99
158160
- filename: src/projects/project.inputs.test.ts
159161
checksum: f8dbd017bbe58700d010881d9bc92f6e640260668d27c91cfe1bb4ee9d1942b6
160162
- filename: test/integration/projects-command-flows.test.ts
161-
checksum: 27cf0618ef66f038e0bc70988af281971a3511b97dd4cbc584591b78ec884042
163+
checksum: 58364e1ed8e4958fe32bfa86e1598c156ab260d495f2700690b0443e3cc670b9
162164
- filename: src/core/redact.test.ts
163165
checksum: ae497ea8c3a4c3f12217d2c4c028a1da3d1324e18362d68c355ba8120902bcb3
164166
- filename: src/commands/launch/projects/list.test.ts

‎AGENTS.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ A new resource - environments, variables, deployments, logs, cache - is a folder
201201
`T` is the value type and `D` names the inputs it depends on, so `resolved.org` is a
202202
`string` inside a prompt or normalize callback rather than something to cast. A spec
203203
may carry `configPath` (a key of `ProjectConfig`, checked at compile time),
204-
`dependsOn`, `prompt`, `normalize` and `default`.
204+
`configLabel` (how the config-file notice names it; required alongside `configPath`,
205+
enforced by `src/resources.test.ts`), `dependsOn`, `prompt`, `normalize` and `default`.
205206
5. **`src/resources.ts`** - spread the new flags into `catalog`, the new specs into
206207
`resolution`, the new dependencies into `DEPENDENCIES`, and add the repository to
207208
`ApiSurface` / `buildApi`. This is the one shared file, so expect to rebase on it.
@@ -441,6 +442,18 @@ from a typo in it is the wrong trade. A missing file is simply written fresh.
441442
it (`projectConfigLoader`), so a V1 file whose branch blocks disagree does not block a command whose
442443
flags already supply everything; a file named with `--config` is still read at once.
443444

445+
**The config-file notice.** When any input came from the file, `LaunchCommand.init` prints one line
446+
through `this.ux.print` (stdout, like every other line the CLI prints), worded by
447+
`configSourceNotice` in `src/core/project-config.ts` from the `configLabel` of each such input and the
448+
**full path** of the file actually read (a relative `--config` is resolved first): `Using the
449+
organization and project UIDs from /Users/me/site/.cs-launch.json.` (one input: `Using the project UID
450+
from …`). It is the first line a command prints. There is no `info:` prefix; only the path is cyan -
451+
the one style here that means neither success (green) nor warning (yellow) - and only when
452+
`outputIsTTY`; the rest of the line is plain. It names no uid and no override hint by decision, prints
453+
with or without a terminal, and says nothing when every value came from a flag, a prompt or a
454+
default. `resolveInputsTraced` is what reports where each value came from; `resolveInputs` is the
455+
same resolution without the sources.
456+
444457
The store's second constructor argument says whether the path was one the **user named**.
445458
At the implicit default path a missing or unreadable file is simply an empty config; at a
446459
path the user passed with `--config` it is a `UsageError` naming the path, because silence

‎src/core/launch-command.test.ts‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
1+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
33
import { resolve as resolvePath, join } from 'node:path';
44

@@ -11,6 +11,7 @@ import { CancelledError, MissingInputError, UsageError } from './errors';
1111
import { exactlyOneOf } from './rules';
1212
import { LaunchApiError } from '../transport/errors';
1313
import { UxLike } from './render';
14+
import type { FlagKey } from '../resources';
1415
import { LaunchCommand, projectConfigLoader, resolveLaunchContext } from './launch-command';
1516
import * as serviceContext from './service-context';
1617
import { SearchListClass, utilitiesLoader } from './search-list';
@@ -525,6 +526,69 @@ describe('LaunchCommand.init terminal detection', () => {
525526
});
526527
});
527528

529+
class ProbeWithOrg extends LaunchCommand {
530+
static flags = {};
531+
static inputs = { org: {} };
532+
533+
async run(): Promise<void> {
534+
return undefined;
535+
}
536+
}
537+
538+
describe('LaunchCommand.init config file notice', () => {
539+
const tempDirs: string[] = [];
540+
541+
afterEach(() => {
542+
tempDirs.forEach((dir) => rmSync(dir, { recursive: true, force: true }));
543+
tempDirs.length = 0;
544+
});
545+
546+
async function initInFolderHoldingAnOrg(
547+
flags: Record<string, unknown>,
548+
): Promise<{ printed: string[]; configFile: string }> {
549+
const dir = mkdtempSync(join(tmpdir(), 'launch-cli-'));
550+
tempDirs.push(dir);
551+
writeFileSync(join(dir, PROJECT_CONFIG_FILE), JSON.stringify({ project: { organizationUid: 'org-from-cwd' } }));
552+
jest.spyOn(process, 'cwd').mockReturnValue(dir);
553+
jest.spyOn(authHandler, 'isAuthenticated').mockReturnValue(true);
554+
const instance = new ProbeWithOrg([], {} as never);
555+
Object.defineProperty(instance, 'launchRegion', {
556+
value: { launchHubUrl: 'https://launch-api.test' },
557+
configurable: true,
558+
});
559+
Object.defineProperty(instance, 'config', { value: { userAgent: 'cli/2.0.0' }, configurable: true });
560+
(instance as unknown as { parse: jest.Mock }).parse = jest.fn().mockResolvedValue({ flags });
561+
const printed: string[] = [];
562+
const ux: UxLike = { print: (message) => printed.push(message), inquire: async () => undefined as never };
563+
Object.defineProperty(instance, 'ux', { value: ux, configurable: true });
564+
565+
await instance.init();
566+
567+
return { printed, configFile: join(dir, PROJECT_CONFIG_FILE) };
568+
}
569+
570+
it('prints the notice on stdout when a value came from the config file', async () => {
571+
const { printed, configFile } = await initInFolderHoldingAnOrg({});
572+
573+
expect(printed).toEqual([`Using the organization UID from ${configFile}.`]);
574+
});
575+
576+
it('colours only the config file path cyan when stdout is a terminal', async () => {
577+
const restore = stdoutReportingTTY(true);
578+
579+
const { printed, configFile } = await initInFolderHoldingAnOrg({});
580+
restore();
581+
582+
expect(printed).toEqual([`Using the organization UID from \u001b[36m${configFile}\u001b[39m.`]);
583+
});
584+
585+
it('prints nothing when every value came from a flag', async () => {
586+
const { printed } = await initInFolderHoldingAnOrg({ org: 'org-from-flag' });
587+
588+
expect(printed).toEqual([]);
589+
});
590+
});
591+
528592
describe('LaunchCommand.init rules', () => {
529593
it('evaluates the rules the subclass declares as a static rules array', async () => {
530594
const instance = new ProbeWithRules([], {} as never) as ProbeWithRules & { error: jest.Mock };
@@ -637,6 +701,57 @@ describe('resolveLaunchContext', () => {
637701
expect(result.services.ux).toBe(ux);
638702
expect(result.services.isTTY).toBe(true);
639703
});
704+
705+
async function contextInFolderHoldingAnOrg(flags: Partial<Record<FlagKey, unknown>>) {
706+
const dir = mkdtempSync(join(tmpdir(), 'launch-cli-'));
707+
tempDirs.push(dir);
708+
writeFileSync(join(dir, PROJECT_CONFIG_FILE), JSON.stringify({ project: { organizationUid: 'org-from-cwd' } }));
709+
jest.spyOn(process, 'cwd').mockReturnValue(dir);
710+
711+
return resolveLaunchContext({
712+
flags,
713+
inputs: { org: {} },
714+
launchHubUrl: 'https://launch-api.test',
715+
analyticsInfo: 'cli/2.0.0',
716+
ux: { print: () => undefined, inquire: async () => undefined as never },
717+
isTTY: false,
718+
});
719+
}
720+
721+
it('words a notice naming the values the config file supplied and where that file is', async () => {
722+
const result = await contextInFolderHoldingAnOrg({});
723+
724+
expect(result.configNotice).toBe(`Using the organization UID from ${result.configPath}.`);
725+
});
726+
727+
it('names a config file passed by a relative --config path by its full path', async () => {
728+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'launch-cli-')));
729+
tempDirs.push(dir);
730+
writeFileSync(join(dir, 'staging.json'), JSON.stringify({ project: { organizationUid: 'org-from-staging' } }));
731+
const startedIn = process.cwd();
732+
process.chdir(dir);
733+
734+
try {
735+
const result = await resolveLaunchContext({
736+
flags: { config: 'staging.json' },
737+
inputs: { org: {} },
738+
launchHubUrl: 'https://launch-api.test',
739+
analyticsInfo: 'cli/2.0.0',
740+
ux: { print: () => undefined, inquire: async () => undefined as never },
741+
isTTY: false,
742+
});
743+
744+
expect(result.configNotice).toBe(`Using the organization UID from ${join(dir, 'staging.json')}.`);
745+
} finally {
746+
process.chdir(startedIn);
747+
}
748+
});
749+
750+
it('words no notice when the flags supplied every value the config file also holds', async () => {
751+
const result = await contextInFolderHoldingAnOrg({ org: 'org-from-flag' });
752+
753+
expect(result.configNotice).toBeUndefined();
754+
});
640755
});
641756

642757
describe('projectConfigLoader', () => {

‎src/core/launch-command.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { Command } from '@contentstack/cli-command';
44
import { cliux, configHandler, isAuthenticated } from '@contentstack/cli-utilities';
55

66
import { EXIT_RUNTIME, PROJECT_CONFIG_FILE, STDIN_MAX_LISTENERS } from './constants';
7-
import { ProjectConfig, ProjectConfigStore } from './project-config';
7+
import { ProjectConfig, ProjectConfigStore, configSourceNotice } from './project-config';
88
import { RegionLike, resolveLaunchHubUrl } from './region';
99
import { LaunchError, UsageError } from './errors';
10-
import { catalog, FlagKey } from '../resources';
10+
import { catalog, FlagKey, resolutionTable } from '../resources';
1111
import { AnyInputs, Resolved } from './inputs';
12-
import { resolveInputs } from './resolve';
12+
import { resolveInputsTraced } from './resolve';
1313
import { Rule } from './rules';
1414
import { cancelOnInterrupt } from './interruptible-ux';
1515
import { UxLike } from './render';
@@ -34,6 +34,7 @@ export interface ResolveLaunchContextResult<S extends AnyInputs> {
3434
resolved: Resolved<S>;
3535
dataDir: string;
3636
configPath: string;
37+
configNotice?: string;
3738
}
3839

3940
export async function resolveLaunchContext<S extends AnyInputs>(
@@ -53,14 +54,20 @@ export async function resolveLaunchContext<S extends AnyInputs>(
5354
outputIsTTY: args.outputIsTTY,
5455
});
5556

56-
const resolved = await resolveInputs(args.inputs, {
57+
const { resolved, sources } = await resolveInputsTraced(args.inputs, {
5758
parsed: args.flags,
5859
projectConfig: projectConfigLoader(new ProjectConfigStore(configPath, Boolean(namedConfig)), Boolean(namedConfig)),
5960
services,
6061
rules: args.rules,
6162
});
6263

63-
return { services, resolved, dataDir, configPath };
64+
const configLabels = Object.entries(sources)
65+
.filter(([, source]) => source === 'config')
66+
.map(([key]) => resolutionTable[key as FlagKey].configLabel)
67+
.filter((label): label is string => label !== undefined);
68+
const configNotice = configSourceNotice(configLabels, resolvePath(configPath), args.outputIsTTY === true);
69+
70+
return { services, resolved, dataDir, configPath, configNotice };
6471
}
6572

6673
export function projectConfigLoader(store: ProjectConfigStore, named: boolean): ProjectConfig | (() => ProjectConfig) {
@@ -116,7 +123,7 @@ export abstract class LaunchCommand<S extends AnyInputs = AnyInputs> extends Com
116123

117124
const region = this.launchRegion ?? {};
118125

119-
const { services, resolved, dataDir, configPath } = await resolveLaunchContext<S>({
126+
const { services, resolved, dataDir, configPath, configNotice } = await resolveLaunchContext<S>({
120127
flags,
121128
inputs: this.contract.inputs as S,
122129
rules: this.contract.rules,
@@ -133,6 +140,10 @@ export abstract class LaunchCommand<S extends AnyInputs = AnyInputs> extends Com
133140
this.resolved = resolved;
134141
this.dataDir = dataDir;
135142
this.configPath = configPath;
143+
144+
if (configNotice !== undefined) {
145+
this.ux.print(configNotice);
146+
}
136147
}
137148

138149
protected async confirm(message: string): Promise<boolean> {

‎src/core/project-config.test.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
44

55
import { UsageError } from './errors';
6-
import { DEFAULT_BLOCK_KEY, ProjectConfigStore } from './project-config';
6+
import { DEFAULT_BLOCK_KEY, ProjectConfigStore, configSourceNotice } from './project-config';
77

88
const tempDirs: string[] = [];
99

@@ -410,3 +410,35 @@ describe('ProjectConfigStore.save', () => {
410410
expect(new ProjectConfigStore(path).load()).toEqual({ uid: 'p1', organizationUid: 'org1' });
411411
});
412412
});
413+
414+
describe('configSourceNotice', () => {
415+
it('names the one value it took from the config file', () => {
416+
expect(configSourceNotice(['project'], '/work/site/.cs-launch.json', false)).toBe(
417+
'Using the project UID from /work/site/.cs-launch.json.',
418+
);
419+
});
420+
421+
it('joins two values with "and" and speaks of UIDs', () => {
422+
expect(configSourceNotice(['organization', 'project'], '/work/site/.cs-launch.json', false)).toBe(
423+
'Using the organization and project UIDs from /work/site/.cs-launch.json.',
424+
);
425+
});
426+
427+
it('separates three or more values with commas and puts "and" before the last', () => {
428+
const labels = ['organization', 'project', 'environment'];
429+
430+
expect(configSourceNotice(labels, '/work/site/.cs-launch.json', false)).toBe(
431+
'Using the organization, project and environment UIDs from /work/site/.cs-launch.json.',
432+
);
433+
});
434+
435+
it('names a config file the user pointed at by its full path', () => {
436+
expect(configSourceNotice(['project'], '/work/shared/envs/staging.json', false)).toBe(
437+
'Using the project UID from /work/shared/envs/staging.json.',
438+
);
439+
});
440+
441+
it('has nothing to say when no value came from the config file', () => {
442+
expect(configSourceNotice([], '/work/site/.cs-launch.json', false)).toBeUndefined();
443+
});
444+
});

‎src/core/project-config.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22

33
import { UsageError } from './errors';
4+
import { styled } from './style';
45

56
export interface ProjectConfig {
67
uid?: string | null;
@@ -192,3 +193,14 @@ export class ProjectConfigStore {
192193
return isBlock(parsed) ? parsed : `The config file at '${this.path}' does not hold a project config.`;
193194
}
194195
}
196+
197+
export function configSourceNotice(labels: string[], configPath: string, colour: boolean): string | undefined {
198+
if (labels.length === 0) {
199+
return undefined;
200+
}
201+
202+
const last = labels[labels.length - 1];
203+
const named = labels.length === 1 ? `${last} UID` : `${labels.slice(0, -1).join(', ')} and ${last} UIDs`;
204+
205+
return `Using the ${named} from ${styled(configPath, 'cyan', colour)}.`;
206+
}

‎src/core/resolution.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface LooseArgs {
3232

3333
export interface ResolutionSpec<T, D extends FlagKey = never, R extends T = T> {
3434
configPath?: ProjectConfigKey;
35+
configLabel?: string;
3536
dependsOn?: readonly D[];
3637
prompt?(args: PromptArgs<D>): Promise<T>;
3738
normalize?(value: T, args: NormalizeArgs<D>): Promise<R>;
@@ -40,6 +41,7 @@ export interface ResolutionSpec<T, D extends FlagKey = never, R extends T = T> {
4041

4142
export interface AnyResolutionSpec {
4243
configPath?: ProjectConfigKey;
44+
configLabel?: string;
4345
dependsOn?: readonly FlagKey[];
4446
prompt?(args: LooseArgs): Promise<unknown>;
4547
normalize?(value: unknown, args: LooseNormalizeArgs): Promise<unknown>;

‎src/core/resolve.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FlagKey } from '../resources';
55
import { AnyInputs, inputs } from './inputs';
66
import * as resolutionModule from '../resources';
77
import { AnyResolutionSpec } from './resolution';
8-
import { resolveInputs } from './resolve';
8+
import { resolveInputs, resolveInputsTraced } from './resolve';
99
import { atLeastOneOf, exactlyOneOf } from './rules';
1010

1111
function withResolution<T>(replacement: Record<string, AnyResolutionSpec>, run: () => Promise<T>): Promise<T> {
@@ -802,3 +802,18 @@ describe('MissingInputError', () => {
802802
expect(error.flag).toBe('org');
803803
});
804804
});
805+
806+
describe('resolveInputsTraced', () => {
807+
it('reports where each resolved value came from alongside the values', async () => {
808+
const traced = await resolveInputsTraced(inputs({ org: { required: true }, limit: {}, skip: {} }), {
809+
parsed: { skip: 5 },
810+
projectConfig: { organizationUid: 'from-config' },
811+
services: services(),
812+
});
813+
814+
expect(traced).toEqual({
815+
resolved: { org: 'from-config', limit: 100, skip: 5 },
816+
sources: { org: 'config', limit: 'default', skip: 'flag' },
817+
});
818+
});
819+
});

0 commit comments

Comments
 (0)