Skip to content
Closed
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
129 changes: 129 additions & 0 deletions src/commands/config/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
22 changes: 21 additions & 1 deletion src/commands/config/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -134,5 +141,18 @@ async function applyChange(change: DiffChange): Promise<void> {
});
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}`,
);
}
71 changes: 69 additions & 2 deletions src/commands/config/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 });
});
});
15 changes: 15 additions & 0 deletions src/commands/config/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');

Expand Down
26 changes: 26 additions & 0 deletions src/lib/config-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions src/lib/config-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import type { DiffChange } from './config-diff.js';

type RawMetadata = {
auth?: Record<string, unknown>;
// 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<string, unknown>;
};

/**
Expand All @@ -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'
);
Comment on lines +53 to +56
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat non-plain deployments values as unsupported.
On Line 55, typeof raw.deployments === 'object' also accepts arrays, which can incorrectly mark capability as supported on malformed metadata.

Suggested patch
   return (
     raw?.deployments !== undefined &&
     raw.deployments !== null &&
-    typeof raw.deployments === 'object'
+    typeof raw.deployments === 'object' &&
+    !Array.isArray(raw.deployments)
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
raw?.deployments !== undefined &&
raw.deployments !== null &&
typeof raw.deployments === 'object'
);
raw?.deployments !== undefined &&
raw.deployments !== null &&
typeof raw.deployments === 'object' &&
!Array.isArray(raw.deployments)
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-capabilities.ts` around lines 53 - 56, The current capability
check that marks deployments supported uses the expression "raw?.deployments !==
undefined && raw.deployments !== null && typeof raw.deployments === 'object'",
which treats arrays as valid; update this to explicitly exclude arrays (e.g.,
replace the typeof check with a plain-object guard such as
"!Array.isArray(raw.deployments) && typeof raw.deployments === 'object'" or call
a small helper isPlainObject(raw.deployments)) so that raw.deployments
(referenced in the boolean expression) must be a non-null plain object before
reporting the capability as supported.

}
return false;
}

Expand Down
Loading
Loading