diff --git a/package.json b/package.json index ecf58ab..a56eb10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/cli", - "version": "0.1.76", + "version": "0.1.77", "description": "InsForge CLI - Command line tool for InsForge platform", "type": "module", "bin": { diff --git a/src/commands/config/apply.test.ts b/src/commands/config/apply.test.ts index 27f406f..d825607 100644 --- a/src/commands/config/apply.test.ts +++ b/src/commands/config/apply.test.ts @@ -166,3 +166,132 @@ describe('config apply (capability probe)', () => { rmSync(tmp, { recursive: true, force: true }); }); }); + +describe('config apply — deployments.subdomain', () => { + it('PUTs /api/deployments/slug with the new slug when cloud backend exposes the slice', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: null }, + }; + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync(tomlPath, '[deployments]\nsubdomain = "my-app"\n'); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { applied: unknown[]; skipped: unknown[] }; + expect(result.applied).toHaveLength(1); + expect(result.skipped).toHaveLength(0); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/deployments/slug' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(1); + expect(JSON.parse(putCalls[0][1]!.body as string)).toEqual({ slug: 'my-app' }); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('clears the slug when TOML carries an empty subdomain (PUT slug: null)', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: 'existing-slug' }, + }; + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync(tomlPath, '[deployments]\nsubdomain = ""\n'); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { applied: unknown[]; skipped: unknown[] }; + expect(result.applied).toHaveLength(1); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/deployments/slug' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(1); + expect(JSON.parse(putCalls[0][1]!.body as string)).toEqual({ slug: null }); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('no-op when TOML matches live slug (default-keep)', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: 'my-app' }, + }; + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync(tomlPath, '[deployments]\nsubdomain = "my-app"\n'); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + // When the diff is empty, the apply path emits { applied: false } via + // the no-changes shortcut — the assertion that matters is that no PUT + // is issued. + const result = docs[0] as { applied: false | unknown[] }; + expect(result.applied).toBe(false); + + const putCalls = ossFetchMock.mock.calls.filter((c) => c[1]?.method === 'PUT'); + expect(putCalls).toHaveLength(0); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('skips with named warning when backend predates the deployments slice (self-host or pre-#1259)', async () => { + // Critical version-skew case: a backend without the deployments metadata + // field must NOT receive a PUT to /api/deployments/slug — on self-host + // that endpoint 503s ("Custom slugs are only available in cloud + // environment"), and on a pre-#1259 backend the metadata round-trip + // wouldn't have detected our intent at all. + nextMetadataResponse = { auth: { allowedRedirectUrls: [] } }; + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync(tomlPath, '[deployments]\nsubdomain = "my-app"\n'); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { + applied: unknown[]; + skipped: Array<{ key: string; reason: string }>; + }; + expect(result.applied).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].key).toBe('deployments.subdomain'); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/deployments/slug' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(0); + + rmSync(tmp, { recursive: true, force: true }); + }); +}); diff --git a/src/commands/config/apply.ts b/src/commands/config/apply.ts index 02f3f22..59519d2 100644 --- a/src/commands/config/apply.ts +++ b/src/commands/config/apply.ts @@ -33,9 +33,16 @@ export function registerConfigApplyCommand(cfg: Command): void { const res = await ossFetch('/api/metadata'); const raw = (await res.json()) as { auth?: { allowedRedirectUrls?: string[] }; + deployments?: { customSlug?: string | null }; }; const live: InsforgeConfig = { auth: { allowed_redirect_urls: raw.auth?.allowedRedirectUrls ?? [] }, + // Only populate the deployments slice when the backend exposes it. + // The capability gate (metadataSupports) still does the final check + // — this just gives diffConfig something to compare against. + ...(raw.deployments + ? { deployments: { subdomain: raw.deployments.customSlug ?? null } } + : {}), }; const result = diffConfig({ live, file }); @@ -134,5 +141,18 @@ async function applyChange(change: DiffChange): Promise { }); return; } - throw new Error(`Unsupported change type: ${change.section}.${change.key}`); + if (change.section === 'deployments' && change.key === 'subdomain') { + // Backend (updateSlugRequestSchema) accepts string | null; the diff + // layer already normalized empty-string to null. A conflict on a + // taken slug returns 409 — ossFetch surfaces that as a CLIError with + // the backend's "Slug is already taken" message. + await ossFetch('/api/deployments/slug', { + method: 'PUT', + body: JSON.stringify({ slug: change.to }), + }); + return; + } + throw new Error( + `Unsupported change type: ${(change as DiffChange).section}.${(change as DiffChange).key}`, + ); } diff --git a/src/commands/config/export.test.ts b/src/commands/config/export.test.ts index 9e35e4b..e95ca84 100644 --- a/src/commands/config/export.test.ts +++ b/src/commands/config/export.test.ts @@ -90,7 +90,9 @@ describe('config export (capability probe)', () => { expect(result.config.auth).toEqual({ allowed_redirect_urls: ['https://a.com', 'https://b.com'], }); - expect(result.skipped).toEqual([]); + // Self-host metadata response (no deployments slice) — that section is + // skipped, but the redirect URLs still apply. + expect(result.skipped).toEqual(['deployments.subdomain']); const written = readFileSync(target, 'utf8'); expect(written).toContain('allowed_redirect_urls'); @@ -114,10 +116,75 @@ describe('config export (capability probe)', () => { const result = docs[0] as { config: { auth?: unknown }; skipped: string[] }; expect(result.config.auth).toBeUndefined(); - expect(result.skipped).toEqual(['auth.allowed_redirect_urls']); + expect(result.skipped).toEqual([ + 'auth.allowed_redirect_urls', + 'deployments.subdomain', + ]); // File is still written so future apply cycles work — just empty. expect(existsSync(target)).toBe(true); rmSync(tmp, { recursive: true, force: true }); }); + + it('emits [deployments] when cloud backend exposes a custom slug', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: 'my-app' }, + }; + const target = join(tmp, 'insforge.toml'); + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + 'config', + 'export', + '--out', + target, + '--force', + ]); + + const result = docs[0] as { + config: { deployments?: { subdomain?: string } }; + skipped: string[]; + }; + expect(result.config.deployments).toEqual({ subdomain: 'my-app' }); + expect(result.skipped).toEqual([]); + + const written = readFileSync(target, 'utf8'); + expect(written).toContain('[deployments]'); + expect(written).toContain('subdomain = "my-app"'); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('omits [deployments] when cloud backend has no slug set', async () => { + // Slice present but customSlug: null — the project is on its default + // URL. Emitting subdomain = "" would mean "clear on apply" (which fails + // the 3-char min), so we leave the section out entirely. + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: null }, + }; + const target = join(tmp, 'insforge.toml'); + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + 'config', + 'export', + '--out', + target, + '--force', + ]); + + const result = docs[0] as { + config: { deployments?: unknown }; + skipped: string[]; + }; + expect(result.config.deployments).toBeUndefined(); + expect(result.skipped).toEqual([]); + + const written = readFileSync(target, 'utf8'); + expect(written).not.toContain('[deployments]'); + + rmSync(tmp, { recursive: true, force: true }); + }); }); diff --git a/src/commands/config/export.ts b/src/commands/config/export.ts index 1c06d3f..0bac524 100644 --- a/src/commands/config/export.ts +++ b/src/commands/config/export.ts @@ -46,6 +46,7 @@ export function registerConfigExportCommand(cfg: Command): void { const res = await ossFetch('/api/metadata'); const raw = (await res.json()) as { auth?: { allowedRedirectUrls?: string[] }; + deployments?: { customSlug?: string | null }; }; // Only emit sections the backend actually exposes. The TOML file @@ -65,6 +66,20 @@ export function registerConfigExportCommand(cfg: Command): void { skipped.push('auth.allowed_redirect_urls'); } + const deploymentsSlice = raw?.deployments; + if (deploymentsSlice && typeof deploymentsSlice === 'object') { + // Cloud backend exposes the slice. Only emit a value when a slug + // is actually set — an unset slug means the project is on its + // default URL, and surfacing subdomain = "" in the TOML would + // imply "clear on apply" (and fail the backend's 3-char min). + if (typeof deploymentsSlice.customSlug === 'string' && deploymentsSlice.customSlug) { + config.deployments = { subdomain: deploymentsSlice.customSlug }; + } + } else { + // Self-host or pre-#1259 backend — slice missing entirely. + skipped.push('deployments.subdomain'); + } + const toml = stringifyConfigToml(config); writeFileSync(target, toml, 'utf8'); diff --git a/src/lib/config-capabilities.test.ts b/src/lib/config-capabilities.test.ts index 3b7d9bc..997e8c6 100644 --- a/src/lib/config-capabilities.test.ts +++ b/src/lib/config-capabilities.test.ts @@ -61,6 +61,32 @@ describe('metadataSupports', () => { }); }); +describe('metadataSupports — deployments.subdomain', () => { + const change: DiffChange = { + section: 'deployments', + op: 'modify', + key: 'subdomain', + from: null, + to: 'my-app', + }; + + it('returns true when the deployments slice is present (cloud backend)', () => { + expect(metadataSupports({ deployments: { customSlug: null } }, change)).toBe(true); + }); + + it('returns true when the slice carries a non-null slug', () => { + expect(metadataSupports({ deployments: { customSlug: 'set' } }, change)).toBe(true); + }); + + it('returns false when the slice is omitted (self-host or pre-#1259 backend)', () => { + // Critical version-skew guard: a backend that doesn't expose + // deployments must not receive a slug PUT — self-host's slug endpoint + // 503s, and a pre-#1259 cloud backend would have no metadata round-trip + // to detect the field at all. + expect(metadataSupports({ auth: { allowedRedirectUrls: [] } }, change)).toBe(false); + }); +}); + describe('changePath', () => { it('joins section and key with a dot', () => { expect(changePath(change)).toBe('auth.allowed_redirect_urls'); diff --git a/src/lib/config-capabilities.ts b/src/lib/config-capabilities.ts index ba3eb3d..d707848 100644 --- a/src/lib/config-capabilities.ts +++ b/src/lib/config-capabilities.ts @@ -27,6 +27,9 @@ import type { DiffChange } from './config-diff.js'; type RawMetadata = { auth?: Record; + // Cloud-only slice. Self-host backends omit the key entirely — that's the + // signal we use to gate [deployments] writes (self-host can't honor them). + deployments?: Record; }; /** @@ -43,6 +46,15 @@ export function metadataSupports(raw: RawMetadata, change: DiffChange): boolean 'allowedRedirectUrls' in raw.auth ); } + if (change.section === 'deployments' && change.key === 'subdomain') { + // Presence-only probe: cloud backends always carry `customSlug` in the + // slice (null when unset); self-host omits the whole `deployments` key. + return ( + raw?.deployments !== undefined && + raw.deployments !== null && + typeof raw.deployments === 'object' + ); + } return false; } diff --git a/src/lib/config-diff.ts b/src/lib/config-diff.ts index f24e6e8..aca54a4 100644 --- a/src/lib/config-diff.ts +++ b/src/lib/config-diff.ts @@ -1,12 +1,20 @@ import type { InsforgeConfig } from './config-schema.js'; -export type DiffChange = { - section: 'auth'; - op: 'modify'; - key: 'allowed_redirect_urls'; - from: string[]; - to: string[]; -}; +export type DiffChange = + | { + section: 'auth'; + op: 'modify'; + key: 'allowed_redirect_urls'; + from: string[]; + to: string[]; + } + | { + section: 'deployments'; + op: 'modify'; + key: 'subdomain'; + from: string | null; + to: string | null; + }; export interface DiffSummary { add: number; @@ -54,6 +62,27 @@ export function diffConfig({ live, file }: DiffInput): DiffResult { } } + const fileDeployments = file.deployments; + const liveDeployments = live.deployments ?? {}; + if (fileDeployments && 'subdomain' in fileDeployments) { + const fromV = liveDeployments.subdomain ?? null; + // Empty-string in TOML means "clear the slug" — TOML has no null literal, + // so this is the only way the user can express "unset" without deleting + // the line. The PUT body sends slug: null which the backend interprets + // as clear. + const rawTo = fileDeployments.subdomain; + const toV = rawTo === null || rawTo === '' ? null : rawTo; + if (fromV !== toV) { + changes.push({ + section: 'deployments', + op: 'modify', + key: 'subdomain', + from: fromV, + to: toV, + }); + } + } + return { changes, summary: summarize(changes) }; } diff --git a/src/lib/config-format.ts b/src/lib/config-format.ts index b06dc19..46c597f 100644 --- a/src/lib/config-format.ts +++ b/src/lib/config-format.ts @@ -30,5 +30,10 @@ export function formatPlan(result: DiffResult): string { } function formatChange(c: DiffChange): string { + if (c.section === 'deployments' && c.key === 'subdomain') { + const fromLabel = c.from === null ? '(unset)' : JSON.stringify(c.from); + const toLabel = c.to === null ? '(unset)' : JSON.stringify(c.to); + return `~ ${c.key}: ${fromLabel} → ${toLabel}`; + } return `~ ${c.key}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`; } diff --git a/src/lib/config-schema.ts b/src/lib/config-schema.ts index 4224803..367acd0 100644 --- a/src/lib/config-schema.ts +++ b/src/lib/config-schema.ts @@ -8,12 +8,18 @@ export interface InsforgeConfig { project_id?: string; auth?: AuthConfig; + deployments?: DeploymentsConfig; } export interface AuthConfig { allowed_redirect_urls?: string[]; } +export interface DeploymentsConfig { + // null clears the slug; absent in TOML means default-keep. + subdomain?: string | null; +} + export class ConfigValidationError extends Error { constructor(public readonly path: string, message: string) { super(`config.${path}: ${message}`); @@ -40,6 +46,31 @@ export function validateConfig(input: unknown): InsforgeConfig { } if ('auth' in obj) out.auth = validateAuth(obj.auth); + if ('deployments' in obj) out.deployments = validateDeployments(obj.deployments); + + return out; +} + +function validateDeployments(input: unknown): DeploymentsConfig { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new ConfigValidationError('deployments', 'must be an object'); + } + const obj = input as Record; + const out: DeploymentsConfig = {}; + + if ('subdomain' in obj) { + const v = obj.subdomain; + // Accept null (clear slug) or string. Slug format validation lives on + // the backend (single source of truth: updateSlugRequestSchema) so the + // CLI doesn't drift from server rules. + if (v !== null && typeof v !== 'string') { + throw new ConfigValidationError( + 'deployments.subdomain', + 'must be a string or null', + ); + } + out.subdomain = v; + } return out; } diff --git a/src/lib/config-toml.test.ts b/src/lib/config-toml.test.ts index 21ff312..7a4f8e6 100644 --- a/src/lib/config-toml.test.ts +++ b/src/lib/config-toml.test.ts @@ -47,3 +47,43 @@ describe('stringifyConfigToml', () => { expect(out).not.toContain('[auth]'); }); }); + +describe('parseConfigToml — [deployments]', () => { + it('parses subdomain as a string', () => { + expect(parseConfigToml('[deployments]\nsubdomain = "my-app"\n')).toEqual({ + deployments: { subdomain: 'my-app' }, + }); + }); + + it('parses empty subdomain (the clear-slug signal)', () => { + // TOML has no null literal, so "" is the convention for "unset on apply". + // The diff layer normalizes this to null before sending. + expect(parseConfigToml('[deployments]\nsubdomain = ""\n')).toEqual({ + deployments: { subdomain: '' }, + }); + }); + + it('rejects non-string subdomain', () => { + expect(() => parseConfigToml('[deployments]\nsubdomain = 42\n')).toThrow( + /subdomain.*string or null/, + ); + }); +}); + +describe('stringifyConfigToml — [deployments]', () => { + it('emits [deployments] section when subdomain is a non-empty string', () => { + const out = stringifyConfigToml({ deployments: { subdomain: 'my-app' } }); + expect(out).toContain('[deployments]'); + expect(out).toContain('subdomain = "my-app"'); + }); + + it('omits the section when subdomain is null', () => { + const out = stringifyConfigToml({ deployments: { subdomain: null } }); + expect(out).not.toContain('[deployments]'); + }); + + it('omits the section when subdomain is empty string (avoid emitting clear-signal in export)', () => { + const out = stringifyConfigToml({ deployments: { subdomain: '' } }); + expect(out).not.toContain('[deployments]'); + }); +}); diff --git a/src/lib/config-toml.ts b/src/lib/config-toml.ts index f1067ac..36fb790 100644 --- a/src/lib/config-toml.ts +++ b/src/lib/config-toml.ts @@ -34,5 +34,17 @@ export function stringifyConfigToml(config: InsforgeConfig): string { lines.push(''); } + if (config.deployments) { + // TOML has no null literal, and "" would be ambiguous (clear vs unset). + // Convention: omit the section entirely when subdomain is null/undefined. + // To clear an existing slug via apply, the user writes subdomain = "" — + // the diff/apply layer normalizes empty string to null. + if (typeof config.deployments.subdomain === 'string' && config.deployments.subdomain !== '') { + lines.push('[deployments]'); + lines.push(`subdomain = ${JSON.stringify(config.deployments.subdomain)}`); + lines.push(''); + } + } + return lines.join('\n').replace(/\n+$/, '\n'); }