From 2df36a572d8f3d755c077c707a76b6cb90a6d0c4 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 1 Oct 2026 07:13:28 -0700 Subject: [PATCH 1/2] feat(template): validate command and mountPath, release 0.1.14 --- package.json | 2 +- src/commands/template.ts | 4 ++-- src/template-manifest.ts | 11 +++++++++++ test/template.test.ts | 9 +++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 94ef229..258bc11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "insta", - "version": "0.1.13", + "version": "0.1.14", "type": "module", "description": "InstaCloud CLI — a thin client of the platform control-plane API.", "keywords": [ diff --git a/src/commands/template.ts b/src/commands/template.ts index 7fa6392..42d52c0 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -42,7 +42,7 @@ export function templateListLines(templates: TemplateIndexEntry[]): string[] { // `volume` is the boolean a manifest declares now; `volumeGib` is a size a registry published // before sizing moved to the platform. Both are read: the catalog serves whichever the row carries, // and dropping the size on its own would quietly stop saying the service HAS a disk. -type InfoService = { name: string; type?: string; port?: number; volumeGib?: number; volume?: boolean } +type InfoService = { name: string; type?: string; port?: number; volumeGib?: number; volume?: boolean; mountPath?: string } // The info endpoint may list services as an array or keep the manifest's map shape — render both. export function normalizeInfoServices(raw: unknown): InfoService[] { @@ -94,7 +94,7 @@ export function templateInfoLines(t: TemplateInfo, bold: (s: string) => string = const summary = services.map((s) => { // A size only when the registry still carries one: a manifest names no size any more, so // "persistent /data" is all there is to say until the service exists. - const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? 'persistent /data' : undefined + const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? `persistent ${s.mountPath ?? '/data'}` : undefined const bits = [s.type && s.type !== 'compute' ? s.type : undefined, s.port ? `port ${s.port}` : undefined, disk].filter(Boolean) return `${s.name}${bits.length ? ` (${bits.join(', ')})` : ''}` }) diff --git a/src/template-manifest.ts b/src/template-manifest.ts index e4506de..5dd012c 100644 --- a/src/template-manifest.ts +++ b/src/template-manifest.ts @@ -29,7 +29,10 @@ export type ManifestService = { port?: number healthcheck?: string volume?: boolean // needs a /data disk; the platform owns the size + volumeGib?: number // registry-only field, kept for normalization alwaysOn?: boolean // idle mode; undeclared = the platform default (always-on for compute) + command?: string // entrypoint command override (runtime field) + mountPath?: string // absolute path for volume mount (runtime field) env?: ManifestEnv } @@ -184,6 +187,14 @@ export function validateManifest(m: TemplateManifest): string[] { if (authored.spec !== undefined) { problems.push(`${where}: compute size is the platform's to choose — remove spec`) } + // Validate runtime fields: command and mountPath + if (svc.command !== undefined && (typeof svc.command !== 'string' || !svc.command.trim())) { + problems.push(`${where}.command must be a non-empty string`) + } + if (svc.mountPath !== undefined) { + if (svc.volume !== true) problems.push(`${where}.mountPath requires volume: true`) + else if (typeof svc.mountPath !== 'string' || !svc.mountPath.startsWith('/')) problems.push(`${where}.mountPath must be an absolute path`) + } const env = svc.env ?? {} for (const group of ['fixed', 'generated', 'required', 'optional'] as const) { for (const varName of Object.keys(env[group] ?? {})) { diff --git a/test/template.test.ts b/test/template.test.ts index f5c9a15..a53af4e 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -314,6 +314,15 @@ describe('validateManifest', () => { expect(validateManifest(worker({ healthcheck: '/' })).join('\n')).toMatch(/a worker has no HTTP endpoint/) expect(validateManifest(worker({ alwaysOn: false })).join('\n')).toMatch(/a worker cannot scale to zero/) }) + + it('accepts command and mountPath, and refuses the shapes the platform refuses', () => { + const web = (extra: Record) => + ({ code: 'x', version: '1', services: { app: { type: 'web', image: 'a:1', healthcheck: '/', ...extra } } }) as unknown as TemplateManifest + expect(validateManifest(web({ command: 'run', volume: true, mountPath: '/app/storage' }))).toEqual([]) + expect(validateManifest(web({ command: ' ' })).join('\n')).toMatch(/services\.app\.command must be a non-empty string/) + expect(validateManifest(web({ mountPath: '/a' })).join('\n')).toMatch(/services\.app\.mountPath requires volume: true/) + expect(validateManifest(web({ volume: true, mountPath: 'a' })).join('\n')).toMatch(/services\.app\.mountPath must be an absolute path/) + }) }) describe('parseManifestYaml', () => { From 894716b843f36ef15784f3039e2d4da256cf0644 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 1 Oct 2026 08:15:35 -0700 Subject: [PATCH 2/2] fix(template): refuse runtime fields on managed services, show the mount path in info --- src/commands/template.ts | 1 + src/template-manifest.ts | 2 +- test/template.test.ts | 17 +++++++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index 42d52c0..1cfb328 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -50,6 +50,7 @@ export function normalizeInfoServices(raw: unknown): InfoService[] { name, type: s?.type, port: s?.port, volumeGib: s?.volumeGib ?? s?.volume?.size, volume: s?.volume === true || s?.volumeGib != null || s?.volume?.size != null, + mountPath: typeof s?.mountPath === 'string' ? s.mountPath : undefined, }) if (Array.isArray(raw)) return raw.map((s: any) => one(s?.name ?? '?', s)) if (raw && typeof raw === 'object') return Object.entries(raw as Record).map(([name, s]) => one(name, s)) diff --git a/src/template-manifest.ts b/src/template-manifest.ts index 5dd012c..845ef57 100644 --- a/src/template-manifest.ts +++ b/src/template-manifest.ts @@ -136,7 +136,7 @@ export function validateManifest(m: TemplateManifest): string[] { // platform's own check (provisioning/templateManifest.ts) so an author hears it here. if (type && MANAGED_TYPES.includes(type)) { const bare = svc as Record - for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn']) { + for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn', 'command', 'mountPath']) { if (bare[field] !== undefined) { problems.push(`${where}.${field}: a ${type} service is platform-managed and carries no ${field} — declare it bare ({ type: ${type} })`) } diff --git a/test/template.test.ts b/test/template.test.ts index a53af4e..5b38b20 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -143,8 +143,9 @@ describe('validateManifest', () => { it('accepts a bare managed datastore and refuses every field the platform owns', () => { for (const type of ['postgres', 'redis', 'mysql', 'mongodb'] as const) { expect(validateManifest({ code: 'x', version: '1', services: { store: { type } } } as unknown as TemplateManifest)).toEqual([]) - for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn']) { - const m = { code: 'x', version: '1', services: { store: { type, [field]: true } } } as unknown as TemplateManifest + for (const field of ['image', 'build', 'port', 'healthcheck', 'volume', 'volumeGib', 'spec', 'alwaysOn', 'command', 'mountPath']) { + const value = field === 'mountPath' ? '/x' : true + const m = { code: 'x', version: '1', services: { store: { type, [field]: value } } } as unknown as TemplateManifest expect(validateManifest(m).join('\n')).toContain(`a ${type} service is platform-managed and carries no ${field}`) } } @@ -399,6 +400,8 @@ describe('templateInfoLines', () => { expect(templateInfoLines(boolTpl)).toContain('services (1): agent (web, port 7681, persistent /data)') const sizedTpl = { ...tpl, services: [{ name: 'agent', type: 'web', port: 7681, volumeGib: 10 }] } expect(templateInfoLines(sizedTpl)).toContain('services (1): agent (web, port 7681, 10Gi volume)') + const customMountTpl = { ...tpl, services: [{ name: 'agent', type: 'web', port: 7681, volume: true, mountPath: '/app/storage' }] } + expect(templateInfoLines(customMountTpl)).toContain('services (1): agent (web, port 7681, persistent /app/storage)') }) it('renders header fields, a services summary, and grouped variables', () => { @@ -425,11 +428,13 @@ describe('templateInfoLines', () => { bool: { type: 'worker', volume: true }, worker: { type: 'worker', volume: { size: 5 } }, norm: { volumeGib: 3 }, + custom: { type: 'web', port: 7681, volume: true, mountPath: '/app/storage' }, })).toEqual([ - { name: 'app', type: 'web', port: 80, volumeGib: undefined, volume: false }, - { name: 'bool', type: 'worker', port: undefined, volumeGib: undefined, volume: true }, - { name: 'worker', type: 'worker', port: undefined, volumeGib: 5, volume: true }, - { name: 'norm', type: undefined, port: undefined, volumeGib: 3, volume: true }, + { name: 'app', type: 'web', port: 80, volumeGib: undefined, volume: false, mountPath: undefined }, + { name: 'bool', type: 'worker', port: undefined, volumeGib: undefined, volume: true, mountPath: undefined }, + { name: 'worker', type: 'worker', port: undefined, volumeGib: 5, volume: true, mountPath: undefined }, + { name: 'norm', type: undefined, port: undefined, volumeGib: 3, volume: true, mountPath: undefined }, + { name: 'custom', type: 'web', port: 7681, volumeGib: undefined, volume: true, mountPath: '/app/storage' }, ]) }) it('accepts flat variable arrays too', () => {