@moda-ai/cli 1.9.0 → 1.9.6
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.
- package/dist/auth.js +34 -2
- package/dist/config.d.ts +2 -1
- package/dist/config.js +7 -10
- package/dist/profiles.d.ts +9 -1
- package/dist/profiles.js +110 -7
- package/package.json +2 -3
- package/.env.example +0 -8
package/dist/auth.js
CHANGED
|
@@ -312,12 +312,44 @@ export async function authFetch(path, options = {}, authOptions = {}) {
|
|
|
312
312
|
if (!token) {
|
|
313
313
|
throw new Error('Not authenticated. Run "moda init" to log in.');
|
|
314
314
|
}
|
|
315
|
-
const url = `${resolveModaBaseUrl()}${path}`;
|
|
316
315
|
const headers = new Headers(options.headers);
|
|
317
316
|
headers.set('Authorization', `Bearer ${token}`);
|
|
318
317
|
if (!headers.has('Content-Type') && options.body) {
|
|
319
318
|
headers.set('Content-Type', 'application/json');
|
|
320
319
|
}
|
|
321
|
-
|
|
320
|
+
const origin = new URL(resolveModaBaseUrl()).origin;
|
|
321
|
+
let url = `${resolveModaBaseUrl()}${path}`;
|
|
322
|
+
// Follow redirects manually so EVERY hop is origin-checked, not just the
|
|
323
|
+
// first. Native `redirect: 'follow'` would silently strip the Authorization
|
|
324
|
+
// header on a cross-host hop (per the Fetch spec) — the exact failure that
|
|
325
|
+
// broke moda init (the legacy www → apex 308). Same-host hops keep the header
|
|
326
|
+
// and are safe to follow; any hop that leaves the Moda origin fails fast with
|
|
327
|
+
// an actionable error instead of surfacing as a confusing 401.
|
|
328
|
+
const MAX_REDIRECTS = 5;
|
|
329
|
+
for (let hop = 0;; hop++) {
|
|
330
|
+
const response = await fetch(url, { ...options, headers, redirect: 'manual' });
|
|
331
|
+
if (response.status < 300 || response.status >= 400) {
|
|
332
|
+
return response;
|
|
333
|
+
}
|
|
334
|
+
const location = response.headers.get('location');
|
|
335
|
+
const target = location ? safeResolveUrl(location, url) : null;
|
|
336
|
+
if (!target || target.origin !== origin) {
|
|
337
|
+
throw new Error(`Moda API redirected (${response.status}) to ${location ?? '(unknown)'}. ` +
|
|
338
|
+
'Cross-host redirects strip Authorization and break moda init. ' +
|
|
339
|
+
'Use MODA_BASE_URL=https://moda.dev (not www.moda.dev).');
|
|
340
|
+
}
|
|
341
|
+
if (hop >= MAX_REDIRECTS) {
|
|
342
|
+
throw new Error(`Moda API exceeded ${MAX_REDIRECTS} same-host redirects for ${path}.`);
|
|
343
|
+
}
|
|
344
|
+
url = target.toString();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function safeResolveUrl(location, base) {
|
|
348
|
+
try {
|
|
349
|
+
return new URL(location, base);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
322
354
|
}
|
|
323
355
|
//# sourceMappingURL=auth.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -27,7 +27,8 @@ export declare function saveCliConfig(update: CliConfig): void;
|
|
|
27
27
|
export declare function resolveApiKey(options?: ProfileIOOptions): string | undefined;
|
|
28
28
|
/**
|
|
29
29
|
* Resolve the ingest worker base URL used by `moda workspace snapshot`.
|
|
30
|
-
* Env override ->
|
|
30
|
+
* Env override -> active profile -> legacy config -> production default.
|
|
31
|
+
* Loopback URLs are rejected unless the active profile is `local`.
|
|
31
32
|
*/
|
|
32
33
|
export declare function resolveIngestUrl(options?: ProfileIOOptions): string;
|
|
33
34
|
/**
|
package/dist/config.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { DEFAULT_INGEST_URL, PROD_BASE_URL, getLegacyConfigPath, loadLegacyCliConfig, resolveApiKeyFromActiveProfile, resolveBaseUrl,
|
|
2
|
+
import { DEFAULT_INGEST_URL, PROD_BASE_URL, getLegacyConfigPath, loadLegacyCliConfig, resolveApiKeyFromActiveProfile, resolveBaseUrl, resolveIngestUrl as resolveProfileIngestUrl, resolveTenantIdFromActiveProfile, syncLegacyConfigToProfile, writeLegacyCliConfig, } from './profiles.js';
|
|
3
3
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
4
4
|
export const VERSION = pkg.version;
|
|
5
|
-
// Canonical host is
|
|
5
|
+
// Canonical host is moda.dev. The www host 308-redirects to the apex, and
|
|
6
6
|
// cross-host redirects strip the Authorization header (fetch/undici), so using
|
|
7
|
-
//
|
|
8
|
-
// canonical host directly.
|
|
7
|
+
// www breaks `moda init`'s authenticated calls with a 401. Default to the
|
|
8
|
+
// canonical host directly; resolveBaseUrl() also rewrites legacy www URLs.
|
|
9
9
|
export const MODA_BASE_URL = resolveBaseUrl();
|
|
10
10
|
export function loadCliConfig(options = {}) {
|
|
11
11
|
return loadLegacyCliConfig(options);
|
|
@@ -38,14 +38,11 @@ export function resolveApiKey(options = {}) {
|
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* Resolve the ingest worker base URL used by `moda workspace snapshot`.
|
|
41
|
-
* Env override ->
|
|
41
|
+
* Env override -> active profile -> legacy config -> production default.
|
|
42
|
+
* Loopback URLs are rejected unless the active profile is `local`.
|
|
42
43
|
*/
|
|
43
44
|
export function resolveIngestUrl(options = {}) {
|
|
44
|
-
|
|
45
|
-
return (env.MODA_INGEST_URL ||
|
|
46
|
-
resolveIngestUrlFromActiveProfile(options) ||
|
|
47
|
-
loadCliConfig(options).ingest_url ||
|
|
48
|
-
DEFAULT_INGEST_URL);
|
|
45
|
+
return resolveProfileIngestUrl(options);
|
|
49
46
|
}
|
|
50
47
|
/**
|
|
51
48
|
* Resolve the active tenant id: env override first, then the persisted
|
package/dist/profiles.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const PROFILE_CONFIG_VERSION = 1;
|
|
2
|
-
export declare const PROD_BASE_URL = "https://
|
|
2
|
+
export declare const PROD_BASE_URL = "https://moda.dev";
|
|
3
3
|
export declare const DEFAULT_INGEST_URL = "https://moda-ingest.modas.workers.dev";
|
|
4
4
|
export declare const DEFAULT_PROFILE_NAME = "prod";
|
|
5
5
|
export type ModaEnvironment = 'local' | 'dev' | 'staging' | 'prod';
|
|
@@ -71,6 +71,7 @@ export declare function getLegacyConfigPath(options?: ProfileIOOptions): string;
|
|
|
71
71
|
export declare function loadLegacyCliConfig(options?: ProfileIOOptions): LegacyCliConfig;
|
|
72
72
|
export declare function writeLegacyCliConfig(update: LegacyCliConfig, options?: ProfileIOOptions): LegacyCliConfig;
|
|
73
73
|
export declare function createDefaultProfiles(options?: ProfileIOOptions): Record<string, ModaProfile>;
|
|
74
|
+
export declare function isLoopbackBaseUrl(url: string): boolean;
|
|
74
75
|
export declare function assertValidProfileName(name: string): void;
|
|
75
76
|
export declare function inspectProfileMigrationState(options?: ProfileIOOptions): ProfileMigrationState;
|
|
76
77
|
export declare function loadProfileConfig(options?: ProfileIOOptions): ProfileConfig;
|
|
@@ -82,7 +83,14 @@ export declare function syncLegacyConfigToProfile(legacy: LegacyCliConfig, optio
|
|
|
82
83
|
export declare function secretFilePathFromRef(reference: SecretReference, options?: ProfileIOOptions): string | undefined;
|
|
83
84
|
export declare function resolveSecretReference(reference: SecretReference | undefined, options?: ProfileIOOptions): string | undefined;
|
|
84
85
|
export declare function resolveApiKeyFromActiveProfile(options?: ProfileIOOptions): string | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* Vercel serves moda.dev as canonical and 308-redirects www to the apex. Node
|
|
88
|
+
* fetch strips Authorization on cross-host redirects, which breaks moda init
|
|
89
|
+
* step 3 when the base URL still points at www.
|
|
90
|
+
*/
|
|
91
|
+
export declare function normalizeModaBaseUrl(url: string): string;
|
|
85
92
|
export declare function resolveBaseUrl(options?: ProfileIOOptions): string;
|
|
93
|
+
export declare function resolveIngestUrl(options?: ProfileIOOptions): string;
|
|
86
94
|
export declare function resolveIngestUrlFromActiveProfile(options?: ProfileIOOptions): string | undefined;
|
|
87
95
|
export declare function resolveTenantIdFromActiveProfile(options?: ProfileIOOptions): string | undefined;
|
|
88
96
|
export declare function ensureSecretFilePermissions(path: string, repair?: boolean): SecretFilePermissionStatus;
|
package/dist/profiles.js
CHANGED
|
@@ -2,7 +2,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
|
|
4
4
|
export const PROFILE_CONFIG_VERSION = 1;
|
|
5
|
-
export const PROD_BASE_URL = 'https://
|
|
5
|
+
export const PROD_BASE_URL = 'https://moda.dev';
|
|
6
6
|
export const DEFAULT_INGEST_URL = 'https://moda-ingest.modas.workers.dev';
|
|
7
7
|
export const DEFAULT_PROFILE_NAME = 'prod';
|
|
8
8
|
function getEnv(options = {}) {
|
|
@@ -93,6 +93,65 @@ export function createDefaultProfiles(options = {}) {
|
|
|
93
93
|
prod: profileDefaults('prod', 'prod', now),
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
+
function isLoopbackHostname(hostname) {
|
|
97
|
+
const host = hostname.toLowerCase();
|
|
98
|
+
return (host === 'localhost' ||
|
|
99
|
+
host === '127.0.0.1' ||
|
|
100
|
+
host === '::1' ||
|
|
101
|
+
host === '[::1]');
|
|
102
|
+
}
|
|
103
|
+
export function isLoopbackBaseUrl(url) {
|
|
104
|
+
try {
|
|
105
|
+
return isLoopbackHostname(new URL(url).hostname);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function defaultBaseUrlForEnvironment(environment) {
|
|
112
|
+
if (environment === 'local')
|
|
113
|
+
return 'http://localhost:3000';
|
|
114
|
+
if (environment === 'staging')
|
|
115
|
+
return 'https://staging.moda.dev';
|
|
116
|
+
return PROD_BASE_URL;
|
|
117
|
+
}
|
|
118
|
+
function defaultIngestUrlForEnvironment(environment) {
|
|
119
|
+
if (environment === 'local')
|
|
120
|
+
return 'http://localhost:8787';
|
|
121
|
+
return DEFAULT_INGEST_URL;
|
|
122
|
+
}
|
|
123
|
+
function environmentAllowsLoopback(environment) {
|
|
124
|
+
return environment !== 'prod';
|
|
125
|
+
}
|
|
126
|
+
function sanitizeProfileBaseUrl(url, environment) {
|
|
127
|
+
const normalized = normalizeModaBaseUrl(url);
|
|
128
|
+
if (environmentAllowsLoopback(environment) || !isLoopbackBaseUrl(normalized)) {
|
|
129
|
+
return normalized;
|
|
130
|
+
}
|
|
131
|
+
return PROD_BASE_URL;
|
|
132
|
+
}
|
|
133
|
+
function sanitizeProfileIngestUrl(url, environment) {
|
|
134
|
+
if (!url)
|
|
135
|
+
return url;
|
|
136
|
+
if (environmentAllowsLoopback(environment) || !isLoopbackBaseUrl(url)) {
|
|
137
|
+
return url;
|
|
138
|
+
}
|
|
139
|
+
return DEFAULT_INGEST_URL;
|
|
140
|
+
}
|
|
141
|
+
function enforceNonLoopbackBaseUrl(url, environment) {
|
|
142
|
+
return sanitizeProfileBaseUrl(url, environment);
|
|
143
|
+
}
|
|
144
|
+
function enforceNonLoopbackIngestUrl(url, environment) {
|
|
145
|
+
return sanitizeProfileIngestUrl(url, environment) ?? defaultIngestUrlForEnvironment(environment);
|
|
146
|
+
}
|
|
147
|
+
function activeProfileEnvironment(options) {
|
|
148
|
+
try {
|
|
149
|
+
return getActiveProfile(options).environment;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return 'prod';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
96
155
|
function normalizeEnvironment(value) {
|
|
97
156
|
if (value === 'local' ||
|
|
98
157
|
value === 'dev' ||
|
|
@@ -131,8 +190,8 @@ function normalizeProfile(fallbackName, value, options) {
|
|
|
131
190
|
const defaults = profileDefaults(name, normalizeEnvironment(value.environment), options.now ?? new Date());
|
|
132
191
|
return {
|
|
133
192
|
...defaults,
|
|
134
|
-
base_url: optionalString(value.base_url) ?? defaults.base_url,
|
|
135
|
-
ingest_url: optionalString(value.ingest_url) ?? defaults.ingest_url,
|
|
193
|
+
base_url: sanitizeProfileBaseUrl(optionalString(value.base_url) ?? defaults.base_url, normalizeEnvironment(value.environment)),
|
|
194
|
+
ingest_url: sanitizeProfileIngestUrl(optionalString(value.ingest_url) ?? defaults.ingest_url, normalizeEnvironment(value.environment)),
|
|
136
195
|
tenant_id: optionalString(value.tenant_id),
|
|
137
196
|
workspace_id: optionalString(value.workspace_id),
|
|
138
197
|
api_key_ref: normalizeSecretReference(value.api_key_ref),
|
|
@@ -170,9 +229,12 @@ function applyLegacyAwareness(config, options) {
|
|
|
170
229
|
const migration = inspectProfileMigrationState(options);
|
|
171
230
|
const prod = config.profiles.prod;
|
|
172
231
|
if (prod && (legacy.api_key || legacy.ingest_url || legacy.tenant_id)) {
|
|
232
|
+
const migratedIngestUrl = legacy.ingest_url && !isLoopbackBaseUrl(legacy.ingest_url)
|
|
233
|
+
? legacy.ingest_url
|
|
234
|
+
: prod.ingest_url;
|
|
173
235
|
config.profiles.prod = {
|
|
174
236
|
...prod,
|
|
175
|
-
ingest_url:
|
|
237
|
+
ingest_url: migratedIngestUrl,
|
|
176
238
|
tenant_id: legacy.tenant_id ?? prod.tenant_id,
|
|
177
239
|
api_key_ref: legacy.api_key ? 'legacy:config.api_key' : prod.api_key_ref,
|
|
178
240
|
auth_token_ref: migration.legacy_auth.has_access_token
|
|
@@ -343,17 +405,58 @@ export function resolveApiKeyFromActiveProfile(options = {}) {
|
|
|
343
405
|
return undefined;
|
|
344
406
|
}
|
|
345
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Vercel serves moda.dev as canonical and 308-redirects www to the apex. Node
|
|
410
|
+
* fetch strips Authorization on cross-host redirects, which breaks moda init
|
|
411
|
+
* step 3 when the base URL still points at www.
|
|
412
|
+
*/
|
|
413
|
+
export function normalizeModaBaseUrl(url) {
|
|
414
|
+
try {
|
|
415
|
+
const parsed = new URL(url);
|
|
416
|
+
if (parsed.hostname === 'www.moda.dev') {
|
|
417
|
+
parsed.hostname = 'moda.dev';
|
|
418
|
+
return parsed.toString().replace(/\/$/, '');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// Not a valid URL; return unchanged and let the caller surface the error.
|
|
423
|
+
}
|
|
424
|
+
return url.replace(/\/$/, '');
|
|
425
|
+
}
|
|
346
426
|
export function resolveBaseUrl(options = {}) {
|
|
427
|
+
const environment = activeProfileEnvironment(options);
|
|
347
428
|
const envBase = optionalString(getEnv(options).MODA_BASE_URL);
|
|
348
|
-
if (envBase)
|
|
349
|
-
return envBase;
|
|
429
|
+
if (envBase) {
|
|
430
|
+
return enforceNonLoopbackBaseUrl(normalizeModaBaseUrl(envBase), environment);
|
|
431
|
+
}
|
|
350
432
|
try {
|
|
351
|
-
return getActiveProfile(options).base_url || PROD_BASE_URL;
|
|
433
|
+
return enforceNonLoopbackBaseUrl(normalizeModaBaseUrl(getActiveProfile(options).base_url || PROD_BASE_URL), environment);
|
|
352
434
|
}
|
|
353
435
|
catch {
|
|
354
436
|
return PROD_BASE_URL;
|
|
355
437
|
}
|
|
356
438
|
}
|
|
439
|
+
export function resolveIngestUrl(options = {}) {
|
|
440
|
+
const environment = activeProfileEnvironment(options);
|
|
441
|
+
const envIngest = optionalString(getEnv(options).MODA_INGEST_URL);
|
|
442
|
+
if (envIngest) {
|
|
443
|
+
return enforceNonLoopbackIngestUrl(envIngest, environment);
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const profileIngest = getActiveProfile(options).ingest_url;
|
|
447
|
+
if (profileIngest) {
|
|
448
|
+
return enforceNonLoopbackIngestUrl(profileIngest, environment);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// Fall through to legacy/default below.
|
|
453
|
+
}
|
|
454
|
+
const legacyIngest = optionalString(loadLegacyCliConfig(options).ingest_url);
|
|
455
|
+
if (legacyIngest) {
|
|
456
|
+
return enforceNonLoopbackIngestUrl(legacyIngest, environment);
|
|
457
|
+
}
|
|
458
|
+
return defaultIngestUrlForEnvironment(environment);
|
|
459
|
+
}
|
|
357
460
|
export function resolveIngestUrlFromActiveProfile(options = {}) {
|
|
358
461
|
try {
|
|
359
462
|
return getActiveProfile(options).ingest_url;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moda-ai/cli",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.6",
|
|
4
4
|
"description": "CLI for Moda - AI agent analytics and observability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
"!dist/__tests__",
|
|
14
14
|
"hooks/**",
|
|
15
15
|
"skills/**/SKILL.md",
|
|
16
|
-
"README.md"
|
|
17
|
-
".env.example"
|
|
16
|
+
"README.md"
|
|
18
17
|
],
|
|
19
18
|
"publishConfig": {
|
|
20
19
|
"access": "public"
|
package/.env.example
DELETED