import { execFile } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import { tmpdir } from 'node:os'; import { dirname, join, resolve as pathResolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { afterEach, describe, expect, it } from 'vitest'; const execFileP = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); const DAEMON_ROOT = pathResolve(__dirname, '..'); const REPO_ROOT = pathResolve(__dirname, '../../..'); const CLI_SRC = pathResolve(__dirname, '../src/cli.ts'); const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs'); interface CapturedRequest { method: string; url: string; headers: http.IncomingHttpHeaders; body: string; } interface StubServer { baseUrl: string; requests: CapturedRequest[]; close: () => Promise; } let stub: StubServer | null = null; let tempRoot = ''; afterEach(async () => { if (stub) await stub.close(); stub = null; if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); tempRoot = ''; }); async function startProjectStubServer(deleteResponse: unknown = { ok: true }, shareResponse?: unknown, shareStatus = 200, planResponse?: unknown): Promise { const requests: CapturedRequest[] = []; const server = http.createServer((req, res) => { let raw = ''; req.on('data', (chunk) => { raw += chunk; }); req.on('end', () => { const captured: CapturedRequest = { method: req.method ?? '', url: req.url ?? '', headers: req.headers, body: raw, }; requests.push(captured); res.setHeader('content-type', 'application/json'); if (captured.method === 'DELETE' && captured.url === '/api/projects/project-1/files/nested%2Findex.html') { res.end(JSON.stringify(deleteResponse)); return; } if (captured.method === 'DELETE' && captured.url === '/api/projects/project-1') { res.end(JSON.stringify(deleteResponse)); return; } if (captured.method === 'POST' && captured.url === '/api/projects') { res.statusCode = 201; res.end(JSON.stringify({ project: { id: 'created-project' } })); return; } if (captured.method === 'POST' && captured.url === '/api/projects/source-project/design-system-copy') { res.statusCode = 201; res.end(JSON.stringify({ project: { id: 'design-copy-1', name: 'Design Copy' }, designSystemId: 'user:design-copy-1', conversationId: 'conversation-design-copy', })); return; } if (captured.method === 'POST' && captured.url === '/api/projects/source-project/duplicate') { res.statusCode = 201; res.end(JSON.stringify({ project: { id: 'duplicate-1', name: 'Duplicate Copy' }, conversationId: 'conversation-duplicate', })); return; } if (captured.method === 'POST' && captured.url === '/api/public-file-stops/retry') { res.end(JSON.stringify({ ...JSON.parse(raw), status: 'stopped' })); return; } if (captured.method === 'GET' && captured.url === '/api/projects/project-1/share-state') { res.end(JSON.stringify({ projectId: 'project-1', bindingExists: false, hasEverShared: false, publications: [] })); return; } if (captured.method === 'GET' && captured.url === '/api/projects/project-1') { res.statusCode = 200; res.end(JSON.stringify({ project: { id: 'project-1', name: 'Project One', workspaceId: 'ws-1' }, resolvedDir: '/tmp/projects/project-1', })); return; } if (captured.method === 'GET' && captured.url === '/api/projects/project-1/files') { res.statusCode = 200; res.end(JSON.stringify({ files: [] })); return; } if ( captured.method === 'DELETE' && captured.url === '/api/projects/project-1/files/nested%2Findex.html/publish-public' ) { res.statusCode = 200; res.end(JSON.stringify({ ok: true, slug: 'legacy-public-slug', fileName: 'nested/index.html', })); return; } if (captured.method === 'POST' && captured.url === '/api/projects/project-1/files/nested%2Findex.html/share-plan') { res.statusCode = 200; res.end(JSON.stringify(planResponse ?? { fileCount: 1, totalBytes: 12, exceedsSizeLimit: false, exclusions: [{ path: 'assets/missing.png', reason: 'missing' }] })); return; } if (['POST', 'GET'].includes(captured.method) && captured.url === '/api/projects/project-1/files/nested%2Findex.html/publish-public') { if (shareResponse !== undefined) { res.statusCode = shareStatus; res.end(typeof shareResponse === 'string' ? shareResponse : JSON.stringify(shareResponse)); return; } const publication = { url: 'https://example.invalid/returned-link', slug: 'returned-slug', fileName: 'nested/index.html' }; res.end(JSON.stringify(captured.method === 'GET' ? { publication } : publication)); return; } if (captured.method === 'GET' && captured.url === '/api/workspaces/ws-1/projects?view=team') { res.statusCode = 200; res.end(JSON.stringify({ projects: [ { id: 'project-1', name: 'Project One', visibility: 'team', resourceState: 'active' }, ], })); return; } if (captured.method === 'GET' && captured.url === '/api/workspaces/ws-1/projects') { res.statusCode = 200; res.end(JSON.stringify({ projects: [ { id: 'project-1', name: 'Project One', skillId: null }, ], })); return; } if (captured.method === 'POST' && captured.url === '/api/workspace/invite') { res.statusCode = 200; res.end(JSON.stringify({ results: [{ email: 'teammate@example.com', ok: true, inviteId: 'invite-1' }], })); return; } if (captured.method === 'GET' && captured.url === '/api/workspace/projects/team') { res.statusCode = 200; res.end(JSON.stringify({ projects: [{ projectId: 'team-project-1', displayName: 'Team Project' }], })); return; } if (captured.method === 'GET' && captured.url === '/api/workspace/members') { res.statusCode = 200; res.end(JSON.stringify({ members: [{ memberId: 'member-1', displayName: 'Member One', role: 'admin' }], })); return; } if (captured.method === 'GET' && captured.url === '/api/workspace/skills/team') { res.statusCode = 200; res.end(JSON.stringify({ ids: ['team-skill'], resources: [{ id: 'team-skill' }] })); return; } if (captured.method === 'POST' && captured.url === '/api/workspaces/ws-1/projects/batch-delete') { res.statusCode = 200; res.end(JSON.stringify({ ok: true, deletedProjectIds: ['project-1', 'project-2'] })); return; } // Workspace directory used by `od project list` to auto-resolve the // signed-in workspace when no explicit --workspace/--workspace-member // is supplied (#6679). Mirrors the personal workspace shape returned // by the real daemon GET /api/workspace/directory. if (captured.method === 'GET' && captured.url === '/api/workspace/directory') { res.statusCode = 200; res.end(JSON.stringify({ items: [ { workspaceId: 'ws-personal', workspaceName: 'Personal', workspaceType: 'personal', workspaceMemberId: 'mem-personal', role: 'owner', memberStatus: 'active', lifecycleState: 'active', }, ], activeWorkspaceId: null, })); return; } if (captured.method === 'GET' && captured.url === '/api/workspaces/ws-personal/projects') { res.statusCode = 200; res.end(JSON.stringify({ projects: [ { id: 'bound-project-1', name: 'Bound Project One', skillId: 'skill-1' }, { id: 'bound-project-2', name: 'Bound Project Two', skillId: 'skill-2' }, ], })); return; } // An unbound project catalog (no signed-in workspace / non-vela). if (captured.method === 'GET' && captured.url === '/api/projects') { res.statusCode = 200; res.end(JSON.stringify({ projects: [] })); return; } res.statusCode = 404; res.end(JSON.stringify({ error: { code: 'unexpected-request', message: captured.url } })); }); }); await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen)); const addr = server.address(); if (!addr || typeof addr === 'string') throw new Error('stub server has no address'); return { baseUrl: `http://127.0.0.1:${addr.port}`, requests, close: () => new Promise((resolveClose, rejectClose) => { server.close((err) => (err ? rejectClose(err) : resolveClose())); }), }; } async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.NODE_OPTIONS; try { const { stdout, stderr } = await execFileP(process.execPath, [TSX_CLI, CLI_SRC, ...args], { cwd: DAEMON_ROOT, env, timeout: 15_000, maxBuffer: 4 * 1024 * 1024, }); return { stdout, stderr, code: 0 }; } catch (err) { const failed = err as { stdout?: string; stderr?: string; code?: number | null }; return { stdout: failed.stdout ?? '', stderr: failed.stderr ?? '', code: failed.code ?? 1, }; } } describe('od project CLI', () => { describe.each([false, true])('share error classification, json=%s', json => { const args = ['project', 'share', 'publish', 'project-1', '--path', 'nested/index.html', ...(json ? ['--json'] : [])]; it.each(['flat', 'nested'])('preserves numeric size metadata from a real HTTP 413 (%s)', async shape => { const data = { bytes: 51_000_000, limit: 50_000_000 }; const body = shape === 'flat' ? { error: 'too_large', ...data, plan: { internal: 'do-not-print' } } : { error: { code: 'too_large', message: 'Share exceeds size limit.', data }, plan: { internal: 'do-not-print' } }; stub = await startProjectStubServer(undefined, body, 413); const result = await runCli([...args, '--daemon-url', stub.baseUrl]); expect(result.code).toBe(1); expect(result.stdout).toBe(''); expect(JSON.parse(result.stderr).error).toMatchObject({ code: 'too_large', data }); expect(result.stderr).not.toContain('do-not-print'); expect(stub.requests).toHaveLength(1); }); it.each([ { error: 'Internal human explanation', plan: { internal: 'do-not-print' } }, { error: 'Internal human explanation', message: 'Not a machine code', plan: { internal: 'do-not-print' } }, 'do-not-print', ])('does not classify an unknown HTTP 500 as a connection failure (%j)', async body => { stub = await startProjectStubServer(undefined, body, 500); const result = await runCli([...args, '--daemon-url', stub.baseUrl]); expect(result.code).toBe(1); expect(result.stdout).toBe(''); expect(JSON.parse(result.stderr)).toEqual({ error: { code: 'http-error', message: 'HTTP 500', data: {} } }); }); it.each(['flat', 'nested'])('exposes only safe publication failure detail (%s)', async shape => { const failure = { stage: 'snapshot', reason: 'session_cleanup' }; const error = { code: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE', message: 'Share operation failed.' }; const body = { error: shape === 'flat' ? error.code : error, failure: { ...failure, upstreamStatus: 999, upstreamCode: 'private body', stderr: 'private-output' }, secret: 'private-key' }; stub = await startProjectStubServer(undefined, body, 502); const result = await runCli([...args, '--daemon-url', stub.baseUrl]); expect(result.code).toBe(1); expect(JSON.parse(result.stderr).error).toEqual({ ...error, data: { failure } }); expect(result.stderr).not.toContain('private'); expect(stub.requests).toHaveLength(1); }); it('keeps actual connection failures classified as daemon-not-running', async () => { const closed = await startProjectStubServer(); await closed.close(); const result = await runCli([...args, '--daemon-url', closed.baseUrl]); expect(result.code).toBe(64); expect(result.stdout).toBe(''); expect(JSON.parse(result.stderr).error.code).toBe('daemon-not-running'); }); it('preserves the existing oversized preflight success exit without blockers', async () => { const plan = { fileCount: 1, totalBytes: 51_000_000, exceedsSizeLimit: true, exclusions: [] }; stub = await startProjectStubServer(undefined, undefined, 200, plan); const result = await runCli(['project', 'share', 'preflight', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl, ...(json ? ['--json'] : [])]); expect(result.code).toBe(0); expect(result.stderr).toBe(''); if (json) expect(JSON.parse(result.stdout)).toEqual(plan); else expect(result.stdout).toContain('Share plan: 1 files, 51000000 bytes.'); expect(stub.requests).toHaveLength(1); }); }); it.each([false, true])('resume reports missing workspace rather than an unavailable daemon; nested=%s', async nested => { const code = 'WORKSPACE_CONTEXT_REQUIRED'; const message = 'an explicit workspace context is required'; stub = await startProjectStubServer(undefined, nested ? { error: { code, message } } : { error: code, message }, 400); const result = await runCli(['project', 'share', 'resume', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl, '--json']); expect(result.code).toBe(1); expect(result.stdout).toBe(''); expect(JSON.parse(result.stderr)).toEqual({ error: { code, message, data: {} } }); expect(stub.requests).toHaveLength(1); expect(stub.requests[0]!.headers['x-od-workspace-id']).toBeUndefined(); }); it.each([false, true])('files delete preserves the HTTP response or human output, json=%s', async json => { const body = { ok: true }; stub = await startProjectStubServer(body); const result = await runCli(['files', 'delete', 'project-1', 'nested/index.html', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, ...(json ? ['--json'] : [])]); expect(result.code).toBe(0); expect(result.stderr).toBe(''); if (json) expect(JSON.parse(result.stdout)).toEqual(body); else expect(result.stdout).toBe('[files] deleted nested/index.html\n'); expect(stub.requests).toHaveLength(1); expect(stub.requests[0]).toMatchObject({ method: 'DELETE', url: '/api/projects/project-1/files/nested%2Findex.html', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' } }); }); it.each([false, true])('delete reports per-file residuals without extra requests, json=%s', async json => { const body = { ok: true, shareResiduals: [ { filePath: 'a\n.html', slug: 'a', retrying: true }, { filePath: 'b.html', slug: 'b', retrying: false, code: 'STOP_EXHAUSTED' }, ] }; stub = await startProjectStubServer(body); const result = await runCli(['project', 'delete', 'project-1', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, ...(json ? ['--json'] : [])]); expect(result.code).toBe(0); if (json) { expect(JSON.parse(result.stdout)).toEqual(body); expect(result.stderr).toBe(''); } else { expect(result.stdout).toBe('[project] deleted project-1\n'); expect(result.stderr).toBe('[project] warning: public links may still be live:\n "a\\n.html" ("a"): stop queued for retry\n "b.html" ("b"): action required; no automatic retry\n'); } expect(stub.requests).toHaveLength(1); expect(stub.requests[0]).toMatchObject({ method: 'DELETE', url: '/api/projects/project-1', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' } }); }); it.each([false, true])('delete reports unchanged no-residual success, json=%s', async json => { stub = await startProjectStubServer(); const result = await runCli(['project', 'delete', 'project-1', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, ...(json ? ['--json'] : [])]); expect(result.code).toBe(0); expect(result.stderr).toBe(''); if (json) expect(JSON.parse(result.stdout)).toEqual({ ok: true }); else expect(result.stdout).toBe('[project] deleted project-1\n'); expect(stub.requests).toHaveLength(1); }); it.each(['publish', 'resume', 'get'])('%s reports no-link success without printing undefined or declaring failure', async action => { const body = { status: action !== 'get' ? 'published' : 'active', link: { status: 'unavailable', code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' }, ...(action === 'get' ? { publication: null, slug: 'stable', freshness: 'unknown' } : { receipt: { slug: 'stable', filePath: 'nested/index.html', versionId: 'v1', version: 1, publishedAt: 1, entryPath: 'index.html' } }) }; stub = await startProjectStubServer(undefined, body); const args = ['project', 'share', action, 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl]; const result = await runCli(args); expect(result.code).toBe(0); expect(result.stdout.trim()).toBe('Published; link temporarily unavailable (slug stable).'); expect(result.stdout).not.toContain('undefined'); const json = await runCli([...args, '--json']); expect(json.code).toBe(0); expect(JSON.parse(json.stdout)).toEqual(body); }); it('publish does not announce a project visibility change; JSON remains raw daemon output', async () => { const body = { status: 'published', url: 'https://viewer.example.test/s', madeTeamVisible: true, receipt: { slug: 'stable', filePath: 'nested/index.html', versionId: 'v1', version: 1, publishedAt: 1, entryPath: 'index.html' } }; stub = await startProjectStubServer(undefined, body); const args = ['project', 'share', 'publish', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl]; const human = await runCli(args); expect(human.code).toBe(0); expect(human.stdout.trim()).toBe('https://viewer.example.test/s'); expect(human.stderr).toBe(''); const json = await runCli([...args, '--json']); expect(json.code).toBe(0); expect(json.stderr).toBe(''); expect(JSON.parse(json.stdout)).toMatchObject({ madeTeamVisible: true }); const help = await runCli(['project', 'share', '--help']); expect(help.stdout).toContain('Publishing does not change project team visibility.'); expect(help.stdout).not.toContain('madeTeamVisible'); }); it('retry-stop targets the persisted file intent, never the deleted project DELETE route', async () => { stub = await startProjectStubServer(); const result = await runCli(['project', 'share', 'retry-stop', 'deleted-project', '--path', 'pages/local.html', '--slug', 'stable', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, '--json']); expect(result.code).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ projectId: 'deleted-project', filePath: 'pages/local.html', slug: 'stable', status: 'stopped' }); expect(stub.requests).toHaveLength(1); expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/public-file-stops/retry', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' }, body: JSON.stringify({ projectId: 'deleted-project', filePath: 'pages/local.html', slug: 'stable' }) }); }); it('share status without path reads project binding history through the real CLI process', async () => { stub = await startProjectStubServer(); const result = await runCli(['project', 'share', 'status', 'project-1', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, '--json']); expect(result.code).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ projectId: 'project-1', bindingExists: false, hasEverShared: false, publications: [] }); expect(stub.requests).toHaveLength(1); expect(stub.requests[0]).toMatchObject({ method: 'GET', url: '/api/projects/project-1/share-state', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' } }); }); it('share preflight uses the same HTTP plan endpoint and returns its JSON DTO', async () => { stub = await startProjectStubServer(); const result = await runCli(['project', 'share', 'preflight', 'project-1', '--path', 'nested/index.html', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, '--json']); expect(result.code).toBe(0); expect(result.stderr).toBe(''); expect(JSON.parse(result.stdout)).toEqual({ fileCount: 1, totalBytes: 12, exceedsSizeLimit: false, exclusions: [{ path: 'assets/missing.png', reason: 'missing' }] }); expect(stub.requests).toHaveLength(1); expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/projects/project-1/files/nested%2Findex.html/share-plan', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' }, body: '' }); }); const conflictBlocker = { code: 'entry-index-conflict', path: 'index.html', entryPath: 'nested/index.html', referencedFrom: 'nested/index.html', suggestedName: 'home.html', referrers: [{ file: 'nested/index.html', reference: '../index.html', attribute: '