@meltstudio/meltctl 4.35.0 → 4.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,9 +5,14 @@ vi.mock('fs-extra', () => ({
5
5
  readFile: vi.fn(),
6
6
  },
7
7
  }));
8
+ const mockClient = vi.hoisted(() => ({
9
+ plans: {
10
+ submit: vi.fn(),
11
+ list: vi.fn(),
12
+ },
13
+ }));
8
14
  vi.mock('../utils/api.js', () => ({
9
- getToken: vi.fn(),
10
- tokenFetch: vi.fn(),
15
+ getClient: vi.fn().mockResolvedValue(mockClient),
11
16
  }));
12
17
  vi.mock('../utils/git.js', () => ({
13
18
  getGitBranch: vi.fn(),
@@ -18,7 +23,6 @@ vi.mock('../utils/git.js', () => ({
18
23
  findMdFiles: vi.fn(),
19
24
  }));
20
25
  import fs from 'fs-extra';
21
- import { getToken, tokenFetch } from '../utils/api.js';
22
26
  import { getGitBranch, getGitCommit, getGitRepository, getProjectName, extractTicketId, findMdFiles, } from '../utils/git.js';
23
27
  import { planSubmitCommand, planListCommand } from './plan.js';
24
28
  beforeEach(() => {
@@ -32,7 +36,6 @@ beforeEach(() => {
32
36
  describe('planSubmitCommand', () => {
33
37
  function setupGitMocks() {
34
38
  ;
35
- getToken.mockResolvedValue('test-token');
36
39
  getGitBranch.mockReturnValue('feature/PROJ-123-add-feature');
37
40
  getGitCommit.mockReturnValue('def5678');
38
41
  getGitRepository.mockReturnValue({
@@ -46,15 +49,14 @@ describe('planSubmitCommand', () => {
46
49
  setupGitMocks();
47
50
  fs.pathExists.mockResolvedValue(true);
48
51
  fs.readFile.mockResolvedValue('# Plan\nSome plan content.');
49
- const mockResponse = {
50
- ok: true,
51
- json: vi.fn().mockResolvedValue({ id: 'plan-100', created: true }),
52
- };
53
- tokenFetch.mockResolvedValue(mockResponse);
52
+ mockClient.plans.submit.mockResolvedValue({
53
+ id: 'plan-100',
54
+ createdAt: '2026-03-26T00:00:00Z',
55
+ updatedAt: '2026-03-26T00:00:00Z',
56
+ created: true,
57
+ });
54
58
  await planSubmitCommand('PROJ-123-plan.md');
55
- const call = tokenFetch.mock.calls[0];
56
- const body = JSON.parse(call[2].body);
57
- expect(body).toEqual({
59
+ expect(mockClient.plans.submit).toHaveBeenCalledWith({
58
60
  project: 'test-project',
59
61
  repository: 'Org/Repo',
60
62
  repositoryUrl: 'https://github.com/Org/Repo.git',
@@ -63,17 +65,19 @@ describe('planSubmitCommand', () => {
63
65
  content: '# Plan\nSome plan content.',
64
66
  metadata: { filename: 'PROJ-123-plan.md' },
65
67
  ticket: 'PROJ-123',
68
+ status: null,
66
69
  });
67
70
  });
68
71
  it('logs "Plan submitted" when API returns created: true', async () => {
69
72
  setupGitMocks();
70
73
  fs.pathExists.mockResolvedValue(true);
71
74
  fs.readFile.mockResolvedValue('plan content');
72
- const mockResponse = {
73
- ok: true,
74
- json: vi.fn().mockResolvedValue({ id: 'plan-new', created: true }),
75
- };
76
- tokenFetch.mockResolvedValue(mockResponse);
75
+ mockClient.plans.submit.mockResolvedValue({
76
+ id: 'plan-new',
77
+ createdAt: '2026-03-26T00:00:00Z',
78
+ updatedAt: '2026-03-26T00:00:00Z',
79
+ created: true,
80
+ });
77
81
  await planSubmitCommand('plan.md');
78
82
  const logCalls = console.log.mock.calls.map((c) => c[0]);
79
83
  expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('Plan submitted'))).toBe(true);
@@ -82,11 +86,12 @@ describe('planSubmitCommand', () => {
82
86
  setupGitMocks();
83
87
  fs.pathExists.mockResolvedValue(true);
84
88
  fs.readFile.mockResolvedValue('updated plan content');
85
- const mockResponse = {
86
- ok: true,
87
- json: vi.fn().mockResolvedValue({ id: 'plan-upd', created: false }),
88
- };
89
- tokenFetch.mockResolvedValue(mockResponse);
89
+ mockClient.plans.submit.mockResolvedValue({
90
+ id: 'plan-upd',
91
+ createdAt: '2026-03-26T00:00:00Z',
92
+ updatedAt: '2026-03-26T00:00:00Z',
93
+ created: false,
94
+ });
90
95
  await planSubmitCommand('plan.md');
91
96
  const logCalls = console.log.mock.calls.map((c) => c[0]);
92
97
  expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('Plan updated'))).toBe(true);
@@ -101,12 +106,7 @@ describe('planSubmitCommand', () => {
101
106
  setupGitMocks();
102
107
  fs.pathExists.mockResolvedValue(true);
103
108
  fs.readFile.mockResolvedValue('content');
104
- const mockResponse = {
105
- ok: false,
106
- statusText: 'Bad Request',
107
- json: vi.fn().mockResolvedValue({ error: 'Invalid content' }),
108
- };
109
- tokenFetch.mockResolvedValue(mockResponse);
109
+ mockClient.plans.submit.mockRejectedValue(new Error('Invalid content'));
110
110
  await expect(planSubmitCommand('plan.md')).rejects.toThrow('process.exit(1)');
111
111
  });
112
112
  it('auto-detects plan file from .plans/ directory when no file provided', async () => {
@@ -115,19 +115,19 @@ describe('planSubmitCommand', () => {
115
115
  extractTicketId.mockReturnValue('PROJ-123');
116
116
  fs.pathExists.mockResolvedValue(true);
117
117
  fs.readFile.mockResolvedValue('auto-detected plan');
118
- const mockResponse = {
119
- ok: true,
120
- json: vi.fn().mockResolvedValue({ id: 'plan-auto', created: true }),
121
- };
122
- tokenFetch.mockResolvedValue(mockResponse);
118
+ mockClient.plans.submit.mockResolvedValue({
119
+ id: 'plan-auto',
120
+ createdAt: '2026-03-26T00:00:00Z',
121
+ updatedAt: '2026-03-26T00:00:00Z',
122
+ created: true,
123
+ });
123
124
  await planSubmitCommand();
124
- expect(tokenFetch).toHaveBeenCalledWith('test-token', '/plans', expect.objectContaining({
125
- method: 'POST',
125
+ expect(mockClient.plans.submit).toHaveBeenCalledWith(expect.objectContaining({
126
+ content: 'auto-detected plan',
126
127
  }));
127
128
  });
128
129
  it('exits with error when no file provided and none auto-detected', async () => {
129
130
  ;
130
- getToken.mockResolvedValue('test-token');
131
131
  findMdFiles.mockResolvedValue([]);
132
132
  getGitBranch.mockReturnValue('main');
133
133
  extractTicketId.mockReturnValue(null);
@@ -136,7 +136,6 @@ describe('planSubmitCommand', () => {
136
136
  });
137
137
  it('does not include ticket in payload when extractTicketId returns null', async () => {
138
138
  ;
139
- getToken.mockResolvedValue('test-token');
140
139
  getGitBranch.mockReturnValue('main');
141
140
  getGitCommit.mockReturnValue('abc1234');
142
141
  getGitRepository.mockReturnValue({
@@ -147,137 +146,101 @@ describe('planSubmitCommand', () => {
147
146
  extractTicketId.mockReturnValue(null);
148
147
  fs.pathExists.mockResolvedValue(true);
149
148
  fs.readFile.mockResolvedValue('plan without ticket');
150
- const mockResponse = {
151
- ok: true,
152
- json: vi.fn().mockResolvedValue({ id: 'plan-no-ticket', created: true }),
153
- };
154
- tokenFetch.mockResolvedValue(mockResponse);
149
+ mockClient.plans.submit.mockResolvedValue({
150
+ id: 'plan-no-ticket',
151
+ createdAt: '2026-03-26T00:00:00Z',
152
+ updatedAt: '2026-03-26T00:00:00Z',
153
+ created: true,
154
+ });
155
155
  await planSubmitCommand('plan.md');
156
- const call = tokenFetch.mock.calls[0];
157
- const body = JSON.parse(call[2].body);
158
- expect(body.ticket).toBeUndefined();
156
+ expect(mockClient.plans.submit).toHaveBeenCalledWith(expect.objectContaining({
157
+ ticket: null,
158
+ }));
159
159
  });
160
160
  it('includes status from frontmatter when present', async () => {
161
161
  setupGitMocks();
162
162
  fs.pathExists.mockResolvedValue(true);
163
163
  fs.readFile.mockResolvedValue('---\nstatus: approved\n---\n# Plan');
164
- const mockResponse = {
165
- ok: true,
166
- json: vi.fn().mockResolvedValue({ id: 'plan-status', created: true }),
167
- };
168
- tokenFetch.mockResolvedValue(mockResponse);
164
+ mockClient.plans.submit.mockResolvedValue({
165
+ id: 'plan-status',
166
+ createdAt: '2026-03-26T00:00:00Z',
167
+ updatedAt: '2026-03-26T00:00:00Z',
168
+ created: true,
169
+ });
169
170
  await planSubmitCommand('plan.md');
170
- const call = tokenFetch.mock.calls[0];
171
- const body = JSON.parse(call[2].body);
172
- expect(body.status).toBe('approved');
171
+ expect(mockClient.plans.submit).toHaveBeenCalledWith(expect.objectContaining({
172
+ status: 'approved',
173
+ }));
173
174
  });
174
175
  it('does not include status when no frontmatter present', async () => {
175
176
  setupGitMocks();
176
177
  fs.pathExists.mockResolvedValue(true);
177
178
  fs.readFile.mockResolvedValue('# Plan\nNo frontmatter here.');
178
- const mockResponse = {
179
- ok: true,
180
- json: vi.fn().mockResolvedValue({ id: 'plan-no-status', created: true }),
181
- };
182
- tokenFetch.mockResolvedValue(mockResponse);
179
+ mockClient.plans.submit.mockResolvedValue({
180
+ id: 'plan-no-status',
181
+ createdAt: '2026-03-26T00:00:00Z',
182
+ updatedAt: '2026-03-26T00:00:00Z',
183
+ created: true,
184
+ });
183
185
  await planSubmitCommand('plan.md');
184
- const call = tokenFetch.mock.calls[0];
185
- const body = JSON.parse(call[2].body);
186
- expect(body.status).toBeUndefined();
186
+ expect(mockClient.plans.submit).toHaveBeenCalledWith(expect.objectContaining({
187
+ status: null,
188
+ }));
187
189
  });
188
190
  });
189
191
  describe('planListCommand', () => {
190
192
  it('calls API with correct query params', async () => {
191
- ;
192
- getToken.mockResolvedValue('test-token');
193
- const mockResponse = {
194
- ok: true,
195
- status: 200,
196
- json: vi.fn().mockResolvedValue({ plans: [], count: 0 }),
197
- };
198
- tokenFetch.mockResolvedValue(mockResponse);
193
+ mockClient.plans.list.mockResolvedValue({ plans: [], count: 0 });
199
194
  await planListCommand({ repository: 'Org/Repo', author: 'dev@meltstudio.co', limit: '5' });
200
- expect(tokenFetch).toHaveBeenCalledWith('test-token', expect.stringContaining('/plans?'));
201
- const url = tokenFetch.mock.calls[0][1];
202
- expect(url).toContain('repository=Org%2FRepo');
203
- expect(url).toContain('author=dev%40meltstudio.co');
204
- expect(url).toContain('limit=5');
195
+ expect(mockClient.plans.list).toHaveBeenCalledWith({
196
+ repository: 'Org/Repo',
197
+ author: 'dev@meltstudio.co',
198
+ limit: 5,
199
+ });
205
200
  });
206
201
  it('exits with error on 403 response', async () => {
207
- ;
208
- getToken.mockResolvedValue('test-token');
209
- const mockResponse = {
210
- ok: false,
211
- status: 403,
212
- statusText: 'Forbidden',
213
- };
214
- tokenFetch.mockResolvedValue(mockResponse);
202
+ mockClient.plans.list.mockRejectedValue(new Error('Access denied. Only Team Managers can list plans.'));
215
203
  await expect(planListCommand({})).rejects.toThrow('process.exit(1)');
216
204
  expect(console.error).toHaveBeenCalled();
217
205
  });
218
206
  it('displays plan list when plans exist', async () => {
219
- ;
220
- getToken.mockResolvedValue('test-token');
221
- const mockResponse = {
222
- ok: true,
223
- status: 200,
224
- json: vi.fn().mockResolvedValue({
225
- plans: [
226
- {
227
- id: '1',
228
- project: 'my-project',
229
- repository: 'Org/Repo',
230
- author: 'dev@meltstudio.co',
231
- branch: 'feature/PROJ-123',
232
- commit: 'abc1234',
233
- ticket: 'PROJ-123',
234
- status: 'submitted',
235
- createdAt: '2026-03-25T10:00:00Z',
236
- updatedAt: '2026-03-25T12:00:00Z',
237
- },
238
- ],
239
- count: 1,
240
- }),
241
- };
242
- tokenFetch.mockResolvedValue(mockResponse);
207
+ mockClient.plans.list.mockResolvedValue({
208
+ plans: [
209
+ {
210
+ id: '1',
211
+ project: 'my-project',
212
+ repository: 'Org/Repo',
213
+ author: 'dev@meltstudio.co',
214
+ branch: 'feature/PROJ-123',
215
+ commit: 'abc1234',
216
+ ticket: 'PROJ-123',
217
+ status: 'submitted',
218
+ createdAt: '2026-03-25T10:00:00Z',
219
+ updatedAt: '2026-03-25T12:00:00Z',
220
+ },
221
+ ],
222
+ count: 1,
223
+ });
243
224
  await planListCommand({});
244
225
  expect(console.log).toHaveBeenCalled();
245
226
  });
246
227
  it('shows "No plans found" when list is empty', async () => {
247
- ;
248
- getToken.mockResolvedValue('test-token');
249
- const mockResponse = {
250
- ok: true,
251
- status: 200,
252
- json: vi.fn().mockResolvedValue({ plans: [], count: 0 }),
253
- };
254
- tokenFetch.mockResolvedValue(mockResponse);
228
+ mockClient.plans.list.mockResolvedValue({ plans: [], count: 0 });
255
229
  await planListCommand({});
256
230
  const logCalls = console.log.mock.calls.map((c) => c[0]);
257
231
  expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('No plans found'))).toBe(true);
258
232
  });
259
233
  it('calls API without query params when no options provided', async () => {
260
- ;
261
- getToken.mockResolvedValue('test-token');
262
- const mockResponse = {
263
- ok: true,
264
- status: 200,
265
- json: vi.fn().mockResolvedValue({ plans: [], count: 0 }),
266
- };
267
- tokenFetch.mockResolvedValue(mockResponse);
234
+ mockClient.plans.list.mockResolvedValue({ plans: [], count: 0 });
268
235
  await planListCommand({});
269
- expect(tokenFetch).toHaveBeenCalledWith('test-token', '/plans');
236
+ expect(mockClient.plans.list).toHaveBeenCalledWith({
237
+ repository: undefined,
238
+ author: undefined,
239
+ limit: undefined,
240
+ });
270
241
  });
271
242
  it('exits with error on non-403 failure response', async () => {
272
- ;
273
- getToken.mockResolvedValue('test-token');
274
- const mockResponse = {
275
- ok: false,
276
- status: 500,
277
- statusText: 'Internal Server Error',
278
- json: vi.fn().mockResolvedValue({ error: 'Server error' }),
279
- };
280
- tokenFetch.mockResolvedValue(mockResponse);
243
+ mockClient.plans.list.mockRejectedValue(new Error('Server error'));
281
244
  await expect(planListCommand({})).rejects.toThrow('process.exit(1)');
282
245
  });
283
246
  });
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { input, editor } from '@inquirer/prompts';
3
- import { isAuthenticated, authenticatedFetch } from '../utils/auth.js';
3
+ import { isAuthenticated } from '../utils/auth.js';
4
+ import { getClient } from '../utils/api.js';
4
5
  const EDITOR_HINT = chalk.dim('(type \\e to open your editor)');
5
6
  async function promptField(message, required) {
6
7
  const value = await input({ message: `${message} ${EDITOR_HINT}` });
@@ -24,11 +25,11 @@ export async function standupCommand(options) {
24
25
  console.error(chalk.red('Not authenticated. Run `npx @meltstudio/meltctl@latest login` first.'));
25
26
  process.exit(1);
26
27
  }
28
+ const client = await getClient();
27
29
  // Check if already submitted today
28
30
  try {
29
- const statusRes = await authenticatedFetch('/standups/status');
30
- if (statusRes.ok) {
31
- const existing = (await statusRes.json());
31
+ const existing = await client.standups.getStatus();
32
+ if (existing) {
32
33
  console.log(chalk.yellow('\nYou already submitted a standup today:\n'));
33
34
  console.log(chalk.bold('Yesterday:'), existing.yesterday);
34
35
  console.log(chalk.bold('Today:'), existing.today);
@@ -58,21 +59,16 @@ export async function standupCommand(options) {
58
59
  today = await promptField('What are you going to work on today?', true);
59
60
  blockers = await promptField('Any blockers? (leave empty if none)', false);
60
61
  }
61
- const res = await authenticatedFetch('/standups', {
62
- method: 'POST',
63
- headers: { 'Content-Type': 'application/json' },
64
- body: JSON.stringify({
62
+ try {
63
+ await client.standups.submit({
65
64
  yesterday,
66
65
  today,
67
66
  blockers: blockers || undefined,
68
- }),
69
- });
70
- if (res.ok) {
67
+ });
71
68
  console.log(chalk.green('\n ✓ Standup submitted!\n'));
72
69
  }
73
- else {
74
- const body = (await res.json());
75
- console.error(chalk.red(`\nFailed to submit standup: ${body.error ?? res.statusText}`));
70
+ catch (error) {
71
+ console.error(chalk.red(`\nFailed to submit standup: ${error instanceof Error ? error.message : 'Unknown error'}`));
76
72
  process.exit(1);
77
73
  }
78
74
  }