@moda-ai/cli 1.1.0 → 1.2.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.
Files changed (59) hide show
  1. package/README.md +36 -0
  2. package/dist/agent.d.ts +5 -0
  3. package/dist/agent.js +477 -0
  4. package/dist/analyst-tui.d.ts +81 -0
  5. package/dist/analyst-tui.js +314 -0
  6. package/dist/api-client.d.ts +4 -0
  7. package/dist/api-client.js +20 -3
  8. package/dist/auth.d.ts +22 -3
  9. package/dist/auth.js +230 -66
  10. package/dist/cli.d.ts +20 -3
  11. package/dist/cli.js +768 -93
  12. package/dist/command-context.d.ts +34 -0
  13. package/dist/command-context.js +38 -0
  14. package/dist/command-registry.d.ts +22 -0
  15. package/dist/command-registry.js +15 -0
  16. package/dist/config.d.ts +41 -2
  17. package/dist/config.js +73 -5
  18. package/dist/doctor.d.ts +79 -0
  19. package/dist/doctor.js +1102 -0
  20. package/dist/errors.d.ts +5 -0
  21. package/dist/errors.js +9 -0
  22. package/dist/harness-report.d.ts +138 -0
  23. package/dist/harness-report.js +1464 -0
  24. package/dist/harness.d.ts +233 -0
  25. package/dist/harness.js +3264 -0
  26. package/dist/init/index.d.ts +30 -1
  27. package/dist/init/index.js +383 -23
  28. package/dist/init/rules.d.ts +5 -1
  29. package/dist/init/rules.js +22 -16
  30. package/dist/init/sdk-guide.d.ts +3 -0
  31. package/dist/init/sdk-guide.js +397 -0
  32. package/dist/init/tenant.js +12 -3
  33. package/dist/keys.d.ts +47 -0
  34. package/dist/keys.js +136 -0
  35. package/dist/output.d.ts +118 -0
  36. package/dist/output.js +228 -0
  37. package/dist/production-intelligence.d.ts +134 -0
  38. package/dist/production-intelligence.js +1113 -0
  39. package/dist/profile-commands.d.ts +10 -0
  40. package/dist/profile-commands.js +252 -0
  41. package/dist/profiles.d.ts +90 -0
  42. package/dist/profiles.js +432 -0
  43. package/dist/prompts.d.ts +6 -0
  44. package/dist/prompts.js +368 -0
  45. package/dist/schemas.d.ts +4 -4
  46. package/dist/skills.d.ts +25 -0
  47. package/dist/skills.js +468 -0
  48. package/dist/sync.d.ts +5 -0
  49. package/dist/sync.js +27 -0
  50. package/dist/telemetry.d.ts +18 -0
  51. package/dist/telemetry.js +63 -0
  52. package/dist/tui.d.ts +40 -0
  53. package/dist/tui.js +387 -0
  54. package/dist/workspace.d.ts +12 -0
  55. package/dist/workspace.js +1294 -0
  56. package/hooks/moda-snapshot.sh +41 -0
  57. package/package.json +10 -7
  58. package/skills/moda-cli/SKILL.md +48 -4
  59. package/skills/integrate-moda/SKILL.md +0 -63
package/dist/auth.js CHANGED
@@ -1,40 +1,160 @@
1
1
  // src/auth.ts — Browser-based authentication for moda init via frontend
2
2
  import { createServer } from 'node:http';
3
- import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import { homedir } from 'node:os';
6
- import { MODA_BASE_URL } from './config.js';
3
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
4
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
5
+ import { resolveModaBaseUrl } from './config.js';
6
+ import { getModaPaths, loadProfileConfig, saveProfileConfig, secretFilePathFromRef, writeSecretJsonFile, } from './profiles.js';
7
7
  const CALLBACK_PORT = 8432;
8
8
  const AUTH_TIMEOUT_MS = 120_000;
9
- const MODA_DIR = join(homedir(), '.moda');
10
- const TOKEN_PATH = join(MODA_DIR, 'auth.json');
11
- export function loadToken() {
9
+ function escapeHtml(value) {
10
+ return value
11
+ .replace(/&/g, '&')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+ function safeEqual(a, b) {
18
+ const aBuf = Buffer.from(a);
19
+ const bBuf = Buffer.from(b);
20
+ if (aBuf.length !== bBuf.length)
21
+ return false;
22
+ return timingSafeEqual(aBuf, bBuf);
23
+ }
24
+ function tokenPath(options = {}) {
25
+ return getModaPaths(options).legacyAuthPath;
26
+ }
27
+ function authTokenRefForProfile(profile) {
28
+ return `file:secrets/auth/${profile}.json`;
29
+ }
30
+ function parseStoredAuth(value) {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
32
+ return null;
33
+ }
34
+ const data = value;
35
+ if (typeof data.access_token !== 'string' ||
36
+ typeof data.expires_at !== 'number') {
37
+ return null;
38
+ }
39
+ return {
40
+ access_token: data.access_token,
41
+ expires_at: data.expires_at,
42
+ profile: typeof data.profile === 'string' ? data.profile : undefined,
43
+ token_type: data.token_type === 'bearer' ? 'bearer' : undefined,
44
+ };
45
+ }
46
+ export function isAuthSessionValid(data, now = Date.now()) {
47
+ if (!data)
48
+ return false;
49
+ return now / 1000 <= data.expires_at - 60;
50
+ }
51
+ function readStoredAuth(path) {
12
52
  try {
13
- if (!existsSync(TOKEN_PATH))
53
+ if (!existsSync(path))
14
54
  return null;
15
- const data = JSON.parse(readFileSync(TOKEN_PATH, 'utf8'));
16
- if (data.expires_at && Date.now() / 1000 > data.expires_at - 60) {
17
- return null; // Expired or about to expire
18
- }
19
- return data.access_token || null;
55
+ return parseStoredAuth(JSON.parse(readFileSync(path, 'utf8')));
20
56
  }
21
57
  catch {
22
58
  return null;
23
59
  }
24
60
  }
25
- function saveToken(accessToken, expiresIn) {
26
- mkdirSync(MODA_DIR, { recursive: true });
61
+ export function loadToken(options = {}) {
62
+ return loadAuthSession(options)?.access_token ?? null;
63
+ }
64
+ export function loadAuthSession(options = {}) {
65
+ const profileSession = loadProfileAuthSession(options);
66
+ if (profileSession)
67
+ return profileSession;
68
+ const legacy = readStoredAuth(tokenPath(options));
69
+ if (!legacy)
70
+ return null;
71
+ if (!options.includeExpired && !isAuthSessionValid(legacy)) {
72
+ return null;
73
+ }
74
+ return legacy;
75
+ }
76
+ function loadProfileAuthSession(options) {
77
+ const profileName = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
78
+ if (!profileName)
79
+ return null;
80
+ try {
81
+ const profile = loadProfileConfig(options).profiles[profileName];
82
+ const tokenRef = profile?.auth_token_ref;
83
+ if (!tokenRef)
84
+ return null;
85
+ const path = secretFilePathFromRef(tokenRef, options);
86
+ if (!path)
87
+ return null;
88
+ const session = readStoredAuth(path);
89
+ if (!session)
90
+ return null;
91
+ if (!options.includeExpired && !isAuthSessionValid(session))
92
+ return null;
93
+ return session;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ export function saveAuthSession(accessToken, expiresIn, options = {}) {
100
+ const profile = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
27
101
  const data = {
28
102
  access_token: accessToken,
29
103
  expires_at: Math.floor(Date.now() / 1000) + expiresIn,
104
+ profile,
105
+ token_type: 'bearer',
30
106
  };
31
- writeFileSync(TOKEN_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
32
- try {
33
- chmodSync(TOKEN_PATH, 0o600);
107
+ if (profile) {
108
+ const ref = authTokenRefForProfile(profile);
109
+ const path = secretFilePathFromRef(ref, options);
110
+ if (!path)
111
+ throw new Error(`Unsupported auth token reference: ${ref}`);
112
+ const config = loadProfileConfig(options);
113
+ const existing = config.profiles[profile];
114
+ if (!existing) {
115
+ throw new Error(`Moda profile '${profile}' does not exist.`);
116
+ }
117
+ writeSecretJsonFile(path, data);
118
+ saveProfileConfig({
119
+ ...config,
120
+ profiles: {
121
+ ...config.profiles,
122
+ [profile]: {
123
+ ...existing,
124
+ auth_token_ref: ref,
125
+ },
126
+ },
127
+ }, options);
128
+ return data;
34
129
  }
35
- catch {
36
- // chmod may fail on some systems, the mode flag above handles it
130
+ writeSecretJsonFile(tokenPath(options), data);
131
+ return data;
132
+ }
133
+ export function clearAuthSession(options = {}) {
134
+ const profile = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
135
+ if (profile) {
136
+ const ref = authTokenRefForProfile(profile);
137
+ const path = secretFilePathFromRef(ref, options);
138
+ if (path)
139
+ rmSync(path, { force: true });
140
+ const config = loadProfileConfig(options);
141
+ const existing = config.profiles[profile];
142
+ if (existing) {
143
+ const { auth_token_ref: _drop, ...profileWithoutToken } = existing;
144
+ saveProfileConfig({
145
+ ...config,
146
+ profiles: {
147
+ ...config.profiles,
148
+ [profile]: profileWithoutToken,
149
+ },
150
+ }, options);
151
+ }
152
+ return;
37
153
  }
154
+ rmSync(tokenPath(options), { force: true });
155
+ }
156
+ function saveToken(accessToken, expiresIn) {
157
+ saveAuthSession(accessToken, expiresIn);
38
158
  }
39
159
  // --- Login Flow ---
40
160
  const SUCCESS_HTML = `<!DOCTYPE html>
@@ -58,97 +178,141 @@ if (h) {
58
178
  }
59
179
  </script>
60
180
  </body></html>`;
61
- export async function login(screen = 'login') {
181
+ async function openAuthUrl(authUrl) {
182
+ const open = (await import('open')).default;
183
+ await open(authUrl);
184
+ }
185
+ function closeAuthServer(server) {
186
+ server.close((err) => {
187
+ if (err && err.code !== 'ERR_SERVER_NOT_RUNNING') {
188
+ // Nothing useful to surface to users during auth teardown.
189
+ }
190
+ });
191
+ // Node 18 keeps idle keep-alive sockets open after server.close(). The browser
192
+ // auth callback is single-use, so close idle sockets aggressively.
193
+ server.closeIdleConnections?.();
194
+ }
195
+ export async function login(screen = 'login', options = {}) {
62
196
  return new Promise((resolve, reject) => {
63
197
  let settled = false;
198
+ let localPort = options.callbackPort ?? CALLBACK_PORT;
199
+ let timeout;
200
+ const state = randomBytes(16).toString('hex');
64
201
  const server = createServer(async (req, res) => {
65
202
  if (settled)
66
203
  return;
67
- const url = new URL(req.url || '/', `http://localhost:${CALLBACK_PORT}`);
204
+ const url = new URL(req.url || '/', `http://localhost:${localPort}`);
68
205
  // Serve fragment extractor at /callback — the frontend redirects here
69
206
  // with token in the URL fragment (#token=...) which never reaches the server.
70
207
  // This page extracts it client-side and forwards to /callback-token as query params.
71
208
  if (url.pathname === '/callback') {
72
- res.writeHead(200, { 'Content-Type': 'text/html' });
209
+ res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
73
210
  res.end(FRAGMENT_EXTRACTOR_HTML);
74
211
  return;
75
212
  }
76
213
  if (url.pathname !== '/callback-token') {
77
- res.writeHead(404);
214
+ res.writeHead(404, { Connection: 'close' });
78
215
  res.end('Not found');
79
216
  return;
80
217
  }
81
218
  const token = url.searchParams.get('token');
82
219
  const expiresIn = url.searchParams.get('expires_in');
83
220
  const error = url.searchParams.get('error');
221
+ const callbackState = url.searchParams.get('state');
222
+ // Bind the callback to this login session: any page in the browser (or
223
+ // any process on localhost) could POST to this endpoint, so a missing or
224
+ // mismatched state value means we must reject before touching `token`.
225
+ if (!callbackState || !safeEqual(callbackState, state)) {
226
+ res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
227
+ res.end('<html><body><h2>Error</h2><p>Invalid authentication state.</p></body></html>', () => {
228
+ fail(new Error('Invalid authentication state.'));
229
+ });
230
+ return;
231
+ }
84
232
  if (error) {
85
- res.writeHead(400, { 'Content-Type': 'text/html' });
86
- res.end(`<html><body><h2>Error</h2><p>${error}</p></body></html>`);
87
- settled = true;
88
- server.close();
89
- reject(new Error(`Authentication error: ${error}`));
233
+ res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
234
+ res.end(`<html><body><h2>Error</h2><p>${escapeHtml(error)}</p></body></html>`, () => {
235
+ fail(new Error(`Authentication error: ${error}`));
236
+ });
90
237
  return;
91
238
  }
92
239
  if (!token) {
93
- res.writeHead(400, { 'Content-Type': 'text/html' });
94
- res.end('<html><body><h2>Error</h2><p>No token received.</p></body></html>');
95
- settled = true;
96
- server.close();
97
- reject(new Error('No token received'));
240
+ res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
241
+ res.end('<html><body><h2>Error</h2><p>No token received.</p></body></html>', () => {
242
+ fail(new Error('No token received'));
243
+ });
98
244
  return;
99
245
  }
100
246
  saveToken(token, parseInt(expiresIn || '86400', 10));
101
- res.writeHead(200, { 'Content-Type': 'text/html' });
102
- res.end(SUCCESS_HTML);
103
- settled = true;
104
- server.close();
105
- resolve(token);
247
+ res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
248
+ res.end(SUCCESS_HTML, () => {
249
+ succeed(token);
250
+ });
106
251
  });
107
- // Timeout
108
- const timeout = setTimeout(() => {
109
- if (!settled) {
110
- settled = true;
111
- server.close();
112
- reject(new Error('Authentication timed out. Please try again.'));
113
- }
114
- }, AUTH_TIMEOUT_MS);
115
- server.on('close', () => clearTimeout(timeout));
252
+ function removeSignalHandlers() {
253
+ process.off('SIGINT', handleSigint);
254
+ process.off('SIGTERM', handleSigterm);
255
+ }
256
+ function finish(callback) {
257
+ if (settled)
258
+ return;
259
+ settled = true;
260
+ if (timeout)
261
+ clearTimeout(timeout);
262
+ removeSignalHandlers();
263
+ // Always attempt to close the server: if SIGINT arrives between
264
+ // server.listen() and its listening callback, the socket may already be
265
+ // bound by the OS. closeAuthServer ignores ERR_SERVER_NOT_RUNNING.
266
+ closeAuthServer(server);
267
+ callback();
268
+ }
269
+ function succeed(token) {
270
+ finish(() => resolve(token));
271
+ }
272
+ function fail(error) {
273
+ finish(() => reject(error));
274
+ }
275
+ function handleSigint() {
276
+ fail(new Error('Authentication cancelled.'));
277
+ }
278
+ function handleSigterm() {
279
+ fail(new Error('Authentication terminated.'));
280
+ }
281
+ timeout = setTimeout(() => {
282
+ fail(new Error('Authentication timed out. Please try again.'));
283
+ }, options.timeoutMs ?? AUTH_TIMEOUT_MS);
116
284
  server.on('error', (err) => {
117
285
  if (err.code === 'EADDRINUSE') {
118
- reject(new Error(`Port ${CALLBACK_PORT} is in use. Close the process using it and try again.`));
286
+ fail(new Error(`Port ${localPort} is in use. Close the process using it and try again.`));
119
287
  }
120
288
  else {
121
- reject(err);
289
+ fail(err);
122
290
  }
123
291
  });
124
- server.listen(CALLBACK_PORT, async () => {
125
- const authUrl = `${MODA_BASE_URL}/auth/cli?port=${CALLBACK_PORT}&screen=${screen}`;
292
+ server.listen(localPort, '127.0.0.1', async () => {
293
+ const address = server.address();
294
+ if (address && typeof address === 'object') {
295
+ localPort = address.port;
296
+ }
297
+ const authUrl = `${resolveModaBaseUrl()}/auth/cli?port=${localPort}&screen=${screen}&state=${state}`;
126
298
  try {
127
- const open = (await import('open')).default;
128
- await open(authUrl);
299
+ await (options.openUrl ?? openAuthUrl)(authUrl);
129
300
  }
130
301
  catch {
131
302
  console.error(` Open this URL in your browser:\n ${authUrl}`);
132
303
  }
133
304
  });
134
- // Clean up on process exit
135
- const cleanup = () => {
136
- if (!settled) {
137
- settled = true;
138
- server.close();
139
- }
140
- };
141
- process.on('SIGINT', cleanup);
142
- process.on('SIGTERM', cleanup);
305
+ process.once('SIGINT', handleSigint);
306
+ process.once('SIGTERM', handleSigterm);
143
307
  });
144
308
  }
145
309
  // --- Authenticated Fetch ---
146
- export async function authFetch(path, options = {}) {
147
- const token = loadToken();
310
+ export async function authFetch(path, options = {}, authOptions = {}) {
311
+ const token = loadToken(authOptions);
148
312
  if (!token) {
149
313
  throw new Error('Not authenticated. Run "moda init" to log in.');
150
314
  }
151
- const url = `${MODA_BASE_URL}${path}`;
315
+ const url = `${resolveModaBaseUrl()}${path}`;
152
316
  const headers = new Headers(options.headers);
153
317
  headers.set('Authorization', `Bearer ${token}`);
154
318
  if (!headers.has('Content-Type') && options.body) {
package/dist/cli.d.ts CHANGED
@@ -1,11 +1,28 @@
1
1
  #!/usr/bin/env node
2
- interface ParsedArgs {
2
+ import { type TelemetryErrorType } from './telemetry.js';
3
+ import { type CommandContext } from './command-context.js';
4
+ import { type CommandDefinition } from './command-registry.js';
5
+ export { CliInputError } from './errors.js';
6
+ export interface ParsedArgs {
3
7
  command: string;
4
8
  positional: string | undefined;
9
+ positionals: string[];
5
10
  flags: Record<string, string>;
6
11
  }
7
12
  export declare function parseArgs(argv: string[]): ParsedArgs;
8
13
  export declare function flagsToArgs(flags: Record<string, string>, positionalKey?: string, positionalValue?: string): Record<string, unknown>;
9
- export declare function runCommand(command: string, positional: string | undefined, flags: Record<string, string>): Promise<void>;
10
- export {};
14
+ export declare function runCommand(command: string, positional: string | undefined, flags: Record<string, string>, positionals?: string[], context?: CommandContext): Promise<void>;
15
+ interface CommandDispatchResult {
16
+ exitCode: number;
17
+ apiKey?: string;
18
+ }
19
+ interface CliCommandDefinition extends CommandDefinition<CommandContext, CommandDispatchResult> {
20
+ validateConfigBeforeRun: boolean;
21
+ resetApiRequestCountBeforeRun: boolean;
22
+ telemetry: 'none' | 'result' | 'result-and-error';
23
+ }
24
+ export declare const commandRegistry: import("./command-registry.js").CommandRegistry<CommandContext, CommandDispatchResult, CliCommandDefinition>;
25
+ export declare function classifyCliError(error: unknown): TelemetryErrorType;
26
+ export declare function printCliError(error: unknown): void;
27
+ export declare function dispatchParsedCommand(parsed: ParsedArgs, startedAt?: number): Promise<number>;
11
28
  //# sourceMappingURL=cli.d.ts.map