Skip to content

Commit 8266859

Browse files
committed
chore: address coderabbit feedback
1 parent 63fd6f0 commit 8266859

11 files changed

Lines changed: 125 additions & 38 deletions

File tree

packages/cli/src/commands/impl/steps/handoff-agents-md.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
CONTEXT_REL_PATH,
99
SETUP_PROMPT_REL_PATH,
1010
} from '../../init/lib/write-context.js'
11-
import type { InitProvider, InitState, InitStep } from '../../init/types.js'
11+
import type { HandoffStep, InitState } from '../../init/types.js'
1212

1313
const AGENTS_MD_REL_PATH = 'AGENTS.md'
1414

@@ -25,10 +25,10 @@ const AGENTS_MD_REL_PATH = 'AGENTS.md'
2525
* tools wouldn't know to look there. Re-runs replace only the sentinel
2626
* region in AGENTS.md.
2727
*/
28-
export const handoffAgentsMdStep: InitStep = {
28+
export const handoffAgentsMdStep: HandoffStep = {
2929
id: 'handoff-agents-md',
3030
name: 'Write AGENTS.md',
31-
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
31+
async run(state: InitState): Promise<InitState> {
3232
const cwd = process.cwd()
3333
const integration = state.integration ?? 'postgresql'
3434

packages/cli/src/commands/impl/steps/handoff-claude.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
CONTEXT_REL_PATH,
66
SETUP_PROMPT_REL_PATH,
77
} from '../../init/lib/write-context.js'
8-
import type { InitProvider, InitState, InitStep } from '../../init/types.js'
8+
import type { HandoffStep, InitState } from '../../init/types.js'
99

1010
const CLAUDE_SKILLS_DIR = '.claude/skills'
1111

@@ -18,10 +18,10 @@ const CLAUDE_INSTALL_URL = 'https://code.claude.com/docs/en/quickstart'
1818
* on PATH we still write the artifacts and print install + manual-launch
1919
* instructions.
2020
*/
21-
export const handoffClaudeStep: InitStep = {
21+
export const handoffClaudeStep: HandoffStep = {
2222
id: 'handoff-claude',
2323
name: 'Hand off to Claude Code',
24-
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
24+
async run(state: InitState): Promise<InitState> {
2525
const cwd = process.cwd()
2626
const integration = state.integration ?? 'postgresql'
2727

packages/cli/src/commands/impl/steps/handoff-codex.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
CONTEXT_REL_PATH,
1010
SETUP_PROMPT_REL_PATH,
1111
} from '../../init/lib/write-context.js'
12-
import type { InitProvider, InitState, InitStep } from '../../init/types.js'
12+
import type { HandoffStep, InitState } from '../../init/types.js'
1313

1414
const AGENTS_MD_REL_PATH = 'AGENTS.md'
1515
const CODEX_SKILLS_DIR = '.codex/skills'
@@ -25,10 +25,10 @@ const CODEX_INSTALL_URL = 'https://github.com/openai/codex'
2525
* AGENTS.md is sentinel-upserted so re-runs replace only our region and
2626
* any user content outside it survives.
2727
*/
28-
export const handoffCodexStep: InitStep = {
28+
export const handoffCodexStep: HandoffStep = {
2929
id: 'handoff-codex',
3030
name: 'Hand off to Codex',
31-
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
31+
async run(state: InitState): Promise<InitState> {
3232
const cwd = process.cwd()
3333
const integration = state.integration ?? 'postgresql'
3434

packages/cli/src/commands/impl/steps/handoff-wizard.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
buildContextFile,
66
writeContextFile,
77
} from '../../init/lib/write-context.js'
8-
import type { InitProvider, InitState, InitStep } from '../../init/types.js'
8+
import type { HandoffStep, InitState } from '../../init/types.js'
99
import { runWizardSpawn } from '../../wizard/index.js'
1010

1111
/**
@@ -20,10 +20,10 @@ import { runWizardSpawn } from '../../wizard/index.js'
2020
* No skills are installed here. The wizard fetches its own agent-side
2121
* prompt from the gateway and runs its own `maybeInstallSkills` flow.
2222
*/
23-
export const handoffWizardStep: InitStep = {
23+
export const handoffWizardStep: HandoffStep = {
2424
id: 'handoff-wizard',
2525
name: 'Use the CipherStash Agent',
26-
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
26+
async run(state: InitState): Promise<InitState> {
2727
const cwd = process.cwd()
2828
const envKeys = state.envKeys ?? []
2929

packages/cli/src/commands/impl/steps/how-to-proceed.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import * as p from '@clack/prompts'
22
import {
33
CancelledError,
44
type HandoffChoice,
5+
type HandoffStep,
56
type InitMode,
6-
type InitProvider,
77
type InitState,
8-
type InitStep,
98
} from '../../init/types.js'
109
import { handoffAgentsMdStep } from './handoff-agents-md.js'
1110
import { handoffClaudeStep } from './handoff-claude.js'
@@ -79,10 +78,10 @@ export function buildOptions(
7978
return options
8079
}
8180

82-
export const howToProceedStep: InitStep = {
81+
export const howToProceedStep: HandoffStep = {
8382
id: 'how-to-proceed',
8483
name: 'How to proceed',
85-
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
84+
async run(state: InitState): Promise<InitState> {
8685
const mode: InitMode = state.mode ?? 'implement'
8786
const message =
8887
mode === 'plan'

packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ describe('parsePlanSummary', () => {
6161
expect(parsePlanSummary(empty)).toBeUndefined()
6262
})
6363

64+
it('rejects an empty columns array — falls back to soft summary', () => {
65+
// An agent that writes `{"columns": []}` (genuinely empty plan,
66+
// truncated write, or chat cutoff) would otherwise render as
67+
// "0 columns across 0 tables — single-deploy", which is misleading.
68+
// `stash impl` falls back to the "open in your editor" panel instead.
69+
const md = `<!-- cipherstash:plan-summary
70+
{"columns": []}
71+
-->`
72+
expect(parsePlanSummary(md)).toBeUndefined()
73+
})
74+
6475
it('tolerates extra unknown fields without dropping the parse', () => {
6576
// Future-proofing — agents may include estimated-deploys or other
6677
// ancillary keys. The parser should ignore them, not fail.

packages/cli/src/commands/init/lib/__tests__/read-context.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,40 @@ describe('readContextFile', () => {
5454
)
5555
expect(readContextFile(cwd)).toBeUndefined()
5656
})
57+
58+
it('returns undefined for an empty object — wrong shape, not "initialized"', () => {
59+
// A `{}` file parses fine but doesn't carry `schemas`/`integration`/
60+
// `packageManager`. Downstream code (status, plan summary, etc.)
61+
// dereferences those without guarding, so accepting `{}` would mean
62+
// a hand-edited or partial-write file crashes the CLI.
63+
writeContext({})
64+
expect(readContextFile(cwd)).toBeUndefined()
65+
})
66+
67+
it('returns undefined when schemas is missing', () => {
68+
writeContext({
69+
integration: 'drizzle',
70+
packageManager: 'pnpm',
71+
// schemas absent
72+
})
73+
expect(readContextFile(cwd)).toBeUndefined()
74+
})
75+
76+
it('returns undefined when integration is the wrong type', () => {
77+
writeContext({
78+
integration: 42,
79+
packageManager: 'pnpm',
80+
schemas: [],
81+
})
82+
expect(readContextFile(cwd)).toBeUndefined()
83+
})
84+
85+
it('returns undefined when schemas is not an array', () => {
86+
writeContext({
87+
integration: 'drizzle',
88+
packageManager: 'pnpm',
89+
schemas: 'oops',
90+
})
91+
expect(readContextFile(cwd)).toBeUndefined()
92+
})
5793
})

packages/cli/src/commands/init/lib/parse-plan.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,15 @@ function isPlanColumn(x: unknown): x is PlanColumn {
4949
function isPlanSummary(x: unknown): x is PlanSummary {
5050
if (!x || typeof x !== 'object') return false
5151
const obj = x as Record<string, unknown>
52-
return Array.isArray(obj.columns) && obj.columns.every(isPlanColumn)
52+
// Empty `columns` is rejected: downstream `renderPlanSummary` would
53+
// produce "0 columns across 0 tables — single-deploy", which is
54+
// misleading. Treating empty as invalid lets `stash impl` fall back
55+
// to the soft "open it in your editor" panel.
56+
return (
57+
Array.isArray(obj.columns) &&
58+
obj.columns.length > 0 &&
59+
obj.columns.every(isPlanColumn)
60+
)
5361
}
5462

5563
/**

packages/cli/src/commands/init/lib/read-context.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,42 @@ import { existsSync, readFileSync } from 'node:fs'
22
import { resolve } from 'node:path'
33
import { CONTEXT_REL_PATH, type ContextFile } from './write-context.js'
44

5+
/**
6+
* Validate that a parsed JSON value has the minimum shape callers rely
7+
* on. We only check the fields downstream code dereferences without
8+
* a guard — `integration`, `packageManager`, and `schemas`. Other
9+
* fields (cliVersion, generatedAt, etc.) are informational and absent
10+
* values won't crash anything.
11+
*
12+
* A wider schema check would belong in a runtime validator (zod, etc.);
13+
* this is the minimum to keep `stash status`, `stash plan`, and `stash
14+
* impl` from hard-failing on a hand-edited or partial-write file.
15+
*/
16+
function isContextFile(x: unknown): x is ContextFile {
17+
if (!x || typeof x !== 'object') return false
18+
const obj = x as Record<string, unknown>
19+
return (
20+
typeof obj.integration === 'string' &&
21+
typeof obj.packageManager === 'string' &&
22+
Array.isArray(obj.schemas)
23+
)
24+
}
25+
526
/**
627
* Read the `.cipherstash/context.json` file written by `stash init`.
7-
* Returns `undefined` when the file is missing or malformed — both `stash
8-
* plan` and `stash impl` use that signal to point the user back at
9-
* `stash init` rather than crashing.
28+
* Returns `undefined` when the file is missing, unparseable, or doesn't
29+
* have the expected shape — both `stash plan` and `stash impl` use that
30+
* signal to point the user back at `stash init` rather than crashing.
1031
*
11-
* Never throws on bad input. Malformed JSON is treated as "no context."
32+
* Never throws on bad input. Malformed JSON and wrong-shape objects are
33+
* both treated as "no context."
1234
*/
1335
export function readContextFile(cwd: string): ContextFile | undefined {
1436
const path = resolve(cwd, CONTEXT_REL_PATH)
1537
if (!existsSync(path)) return undefined
1638
try {
17-
return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile
39+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
40+
return isContextFile(parsed) ? parsed : undefined
1841
} catch {
1942
return undefined
2043
}

packages/cli/src/commands/init/types.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,32 @@ export interface InitState {
6464
mode?: InitMode
6565
}
6666

67+
/**
68+
* A step that runs as part of the `stash init` pipeline. The init
69+
* pipeline owns the `InitProvider` (intro copy, provider-specific
70+
* defaults) and threads it into every step. Some init steps consult it
71+
* (e.g. `authenticateStep` reads `provider.name` for telemetry) so the
72+
* argument is required at the type level — calling
73+
* `authenticateStep.run(state)` without a provider would crash.
74+
*/
6775
export interface InitStep {
6876
id: string
6977
name: string
70-
/** `provider` is optional. The init pipeline passes one (it owns
71-
* intro copy and provider-specific defaults); the post-init handoff
72-
* steps invoked by `stash plan` / `stash impl` don't have a provider
73-
* to give and don't use one. */
74-
run(state: InitState, provider?: InitProvider): Promise<InitState>
78+
run(state: InitState, provider: InitProvider): Promise<InitState>
79+
}
80+
81+
/**
82+
* A step that runs after init has finished — invoked by `stash plan` and
83+
* `stash impl` to drive the agent handoff. These steps don't have an
84+
* `InitProvider` available (init owns that abstraction) and don't need
85+
* one, so the type intentionally omits it. Keeping `InitStep` and
86+
* `HandoffStep` distinct prevents callers from accidentally invoking
87+
* init-only steps from the post-init flow.
88+
*/
89+
export interface HandoffStep {
90+
id: string
91+
name: string
92+
run(state: InitState): Promise<InitState>
7593
}
7694

7795
export interface InitProvider {

0 commit comments

Comments
 (0)