@aion0/forge 0.10.87 → 0.10.89

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/RELEASE_NOTES.md CHANGED
@@ -1,14 +1,14 @@
1
- # Forge v0.10.87
1
+ # Forge v0.10.89
2
2
 
3
3
  Released: 2026-06-17
4
4
 
5
- ## Changes since v0.10.86
5
+ ## Changes since v0.10.88
6
6
 
7
7
  ### Other
8
- - feat(pipeline): log attached skills in task init line
9
- - feat(skills): per-skill source badge in marketplace (#354)
10
- - feat(skills): multi-source sync + install (enterprise + public) — #354
11
- - feat(pipeline): workflow-level skills: field + template _skills auto-install
8
+ - fix(build): clean install on fresh machines webpack build + valid route exports
9
+ - fix(projects): strict resolve requires git-backed match + fresh-clone visibility
10
+ - refactor(projects): auto-clone lands in project root, retire cloned-projects
11
+ - feat(projects): auto-clone lands in a real project root, not the cache
12
12
 
13
13
 
14
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.86...v0.10.87
14
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.88...v0.10.89
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { NextResponse } from 'next/server';
11
- import { isValidToken } from '../verify/route';
11
+ import { isValidToken } from '@/lib/api-tokens';
12
12
 
13
13
  export async function POST(req: Request) {
14
14
  if (!isValidToken(req)) {
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
2
2
  import { verifyAdmin, getAdminPassword } from '@/lib/password';
3
3
  import { createApiKey, listApiKeys } from '@/lib/api-keys';
4
4
  import { isAuthenticated } from '@/lib/api-auth';
5
- import { isValidToken } from '@/app/api/auth/verify/route';
5
+ import { isValidToken } from '@/lib/api-tokens';
6
6
 
7
7
  function isDev(): boolean {
8
8
  return process.env.NODE_ENV !== 'production' || process.env.FORGE_DEV === '1';
@@ -1,23 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { randomUUID } from 'node:crypto';
3
-
4
- // In-memory valid tokens shared across API routes in the same process
5
- const tokenKey = Symbol.for('forge-api-tokens');
6
- const g = globalThis as any;
7
- if (!g[tokenKey]) g[tokenKey] = new Set<string>();
8
- export const validTokens: Set<string> = g[tokenKey];
9
-
10
- /** Verify a token is valid (for use in other API routes) */
11
- export function isValidToken(req: Request): boolean {
12
- // Check header
13
- const headerToken = new Headers(req.headers).get('x-forge-token');
14
- if (headerToken && validTokens.has(headerToken)) return true;
15
- // Check cookie
16
- const cookieHeader = new Headers(req.headers).get('cookie') || '';
17
- const match = cookieHeader.match(/forge-api-token=([^;]+)/);
18
- if (match && validTokens.has(match[1])) return true;
19
- return false;
20
- }
2
+ // Token store/helpers live in lib/api-tokens (a route file may only export
3
+ // HTTP handlers + Next's allowed config fields — Next 16 type-checks this).
4
+ import { mintToken } from '@/lib/api-tokens';
21
5
 
22
6
  export async function POST(req: Request) {
23
7
  const body = await req.json();
@@ -32,8 +16,7 @@ export async function POST(req: Request) {
32
16
  return NextResponse.json({ error: 'invalid password' }, { status: 401 });
33
17
  }
34
18
 
35
- const token = randomUUID();
36
- validTokens.add(token);
19
+ const token = mintToken();
37
20
 
38
21
  const res = NextResponse.json({ ok: true, token });
39
22
  res.cookies.set('forge-api-token', token, {
@@ -15,7 +15,7 @@
15
15
  * forwards JSON-RPC frames here and carries the Mcp-Session-Id header.
16
16
  */
17
17
 
18
- import { isValidToken } from '@/app/api/auth/verify/route';
18
+ import { isValidToken } from '@/lib/api-tokens';
19
19
  import { isAuthenticated } from '@/lib/api-auth';
20
20
  import { createManagementMcpServer } from '@/mcp/server';
21
21
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
@@ -544,7 +544,9 @@ function preprocessTemplate(
544
544
  // / default_project_path) after a Reinstall without forcing the user
545
545
  // to re-run the entire wizard. Existing non-empty values are preserved
546
546
  // — see the merge logic inside.
547
- export async function applyConnectors(
547
+ // Module-local (not exported): a route file may only export HTTP handlers +
548
+ // Next's config fields — exporting helpers fails Next 16's route type-check.
549
+ async function applyConnectors(
548
550
  values: Record<string, string> | undefined,
549
551
  selectedConnectors: string[] | undefined,
550
552
  sourceId?: string,
@@ -758,7 +760,7 @@ export async function applyConnectors(
758
760
  * template omits `_pipelines`, falls back to `suggested_pipelines` (the
759
761
  * marketplace's fortinet-* prefix heuristic).
760
762
  */
761
- export async function applyTemplatePipelines(sourceId?: string, deptId?: string): Promise<{
763
+ async function applyTemplatePipelines(sourceId?: string, deptId?: string): Promise<{
762
764
  installed: string[];
763
765
  errors: Array<{ name: string; error: string }>;
764
766
  }> {
@@ -28,6 +28,32 @@ import { homedir } from 'node:os';
28
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
29
  const ROOT = join(__dirname, '..');
30
30
 
31
+ // Node version guard — Forge is tested on Node 20–22 (LTS). Bleeding-edge
32
+ // majors crash `next build` with a cryptic Turbopack error ("Expected process
33
+ // result to be a module"); old majors lack required APIs. Warn early so the
34
+ // real cause is visible before the build runs.
35
+ {
36
+ const major = Number(process.versions.node.split('.')[0]);
37
+ if (major < 20 || major > 24) {
38
+ console.warn(
39
+ `\n⚠️ Forge is tested on Node 20–22 (LTS); you're on Node ${process.versions.node}.\n` +
40
+ ` If startup fails with a Turbopack / native-module error, switch to Node 22:\n` +
41
+ ` nvm install 22 && nvm use 22 && npm i -g @aion0/forge\n`
42
+ );
43
+ }
44
+ }
45
+
46
+ // Pre-install EnvHttpProxyAgent before any dynamic import('undici') can
47
+ // clobber Node's NODE_USE_ENV_PROXY agent (see lib/proxy-setup.ts).
48
+ // No-op off-corp (no proxy env), so non-docker deployments are unchanged.
49
+ if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY ||
50
+ process.env.https_proxy || process.env.http_proxy) {
51
+ try {
52
+ const { setGlobalDispatcher, EnvHttpProxyAgent } = await import('undici');
53
+ setGlobalDispatcher(new EnvHttpProxyAgent());
54
+ } catch { /* undici unavailable — leave Node's default dispatcher */ }
55
+ }
56
+
31
57
  /** Build Next.js — install devDependencies first if missing */
32
58
  function buildNext() {
33
59
  // Check if devDependencies are installed (e.g. @tailwindcss/postcss)
@@ -40,7 +66,12 @@ function buildNext() {
40
66
  // installed npm-package copy (.npmrc isn't published).
41
67
  execSync('npm install --include=dev --legacy-peer-deps', { cwd: ROOT, stdio: 'inherit' });
42
68
  }
43
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
69
+ // Build with webpack, not Turbopack (Next 16's default). Turbopack chokes on
70
+ // the Proxy/middleware bundle — proxy.ts pulls auth + a route module that
71
+ // transitively imports Node-native deps (better-sqlite3 …) — and dies with
72
+ // "TurbopackInternalError: Expected process result to be a module". Webpack
73
+ // (the pre-16 path) handles the Node-runtime middleware fine.
74
+ execSync('npx next build --webpack', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
44
75
  }
45
76
 
46
77
  /**
@@ -2,6 +2,8 @@
2
2
  * Next.js instrumentation — runs once when the server starts.
3
3
  * Loads .env.local and prints login password.
4
4
  */
5
+ import './lib/proxy-setup'; // MUST be first — see proxy-setup.ts header.
6
+
5
7
  export async function register() {
6
8
  // Only run on server, not Edge
7
9
  if (process.env.NEXT_RUNTIME === 'nodejs') {
@@ -0,0 +1,31 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * In-memory admin-minted API tokens, shared across API routes + the proxy in
5
+ * the same process. Lives in lib/ (not an API route) on purpose: Next 16's
6
+ * route type-check rejects non-handler exports from a route file, and importing
7
+ * a route module into proxy.ts dragged Node-native deps into the middleware
8
+ * bundle — which crashed the Turbopack build.
9
+ */
10
+ const tokenKey = Symbol.for('forge-api-tokens');
11
+ const g = globalThis as any;
12
+ if (!g[tokenKey]) g[tokenKey] = new Set<string>();
13
+ export const validTokens: Set<string> = g[tokenKey];
14
+
15
+ /** Mint + register a new token (called by the password-verify route). */
16
+ export function mintToken(): string {
17
+ const token = randomUUID();
18
+ validTokens.add(token);
19
+ return token;
20
+ }
21
+
22
+ /** True if the request carries a valid token (x-forge-token header or
23
+ * forge-api-token cookie). */
24
+ export function isValidToken(req: Request): boolean {
25
+ const headerToken = new Headers(req.headers).get('x-forge-token');
26
+ if (headerToken && validTokens.has(headerToken)) return true;
27
+ const cookieHeader = new Headers(req.headers).get('cookie') || '';
28
+ const match = cookieHeader.match(/forge-api-token=([^;]+)/);
29
+ if (match && validTokens.has(match[1])) return true;
30
+ return false;
31
+ }
@@ -39,6 +39,7 @@
39
39
  * Usage: npx tsx lib/browser-bridge-standalone.ts [--forge-port=8403]
40
40
  */
41
41
 
42
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
42
43
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
43
44
  import { WebSocket, WebSocketServer } from 'ws';
44
45
  import { randomUUID, createHash } from 'node:crypto';
@@ -241,8 +241,14 @@ async function exchangeBearerToken(
241
241
  const exchangeFetchInit = { method: auth.exchange_method || 'POST', headers, body };
242
242
  let res: Response;
243
243
  if (verifyTls === false) {
244
- const { fetch: undiciFetch, Agent } = await import('undici');
245
- const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
244
+ // Per-call dispatcher bypasses the global proxy agent — route via
245
+ // ProxyAgent when a proxy is set (corp), else plain Agent (direct).
246
+ const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
247
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
248
+ process.env.HTTP_PROXY || process.env.http_proxy;
249
+ const dispatcher = proxyUrl
250
+ ? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
251
+ : new Agent({ connect: { rejectUnauthorized: false } });
246
252
  res = await undiciFetch(exchangeUrl, { ...exchangeFetchInit, dispatcher }) as unknown as Response;
247
253
  } else {
248
254
  res = await fetch(exchangeUrl, exchangeFetchInit);
@@ -488,8 +494,14 @@ export async function runHttp({ tool, settings, args, connectorAuth, noTruncatio
488
494
  // fetch are version-matched; passing an external undici Agent
489
495
  // into Node's bundled global fetch fails with UND_ERR_INVALID_ARG
490
496
  // because Node 22 ships an older undici than the installed one.
491
- const { fetch: undiciFetch, Agent } = await import('undici');
492
- const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
497
+ // Per-call dispatcher bypasses the global proxy agent — route via
498
+ // ProxyAgent when a proxy is set (corp), else plain Agent (direct).
499
+ const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
500
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
501
+ process.env.HTTP_PROXY || process.env.http_proxy;
502
+ const dispatcher = proxyUrl
503
+ ? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
504
+ : new Agent({ connect: { rejectUnauthorized: false } });
493
505
  res = await undiciFetch(url, { method, headers, body, signal: controller.signal, dispatcher }) as unknown as Response;
494
506
  } else {
495
507
  res = await fetch(url, { method, headers, body, signal: controller.signal });
@@ -30,6 +30,7 @@
30
30
  * keep their existing WS path; HTTP-only surfaces use SSE.
31
31
  */
32
32
 
33
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
33
34
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
34
35
  import {
35
36
  createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
@@ -134,8 +134,14 @@ async function runHttpProbe(
134
134
  // NAC, ESXi …) need undici with rejectUnauthorized:false, same as http.ts.
135
135
  const fetchInit = { method, headers, body, signal: ctrl.signal };
136
136
  if (def.http?.verify_tls === false) {
137
- const { fetch: undiciFetch, Agent } = await import('undici');
138
- const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
137
+ // A per-call dispatcher bypasses the global proxy agent, so when a proxy
138
+ // is set (corp) route through ProxyAgent; else plain Agent (direct).
139
+ const { fetch: undiciFetch, Agent, ProxyAgent } = await import('undici');
140
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
141
+ process.env.HTTP_PROXY || process.env.http_proxy;
142
+ const dispatcher = proxyUrl
143
+ ? new ProxyAgent({ uri: proxyUrl, requestTls: { rejectUnauthorized: false } })
144
+ : new Agent({ connect: { rejectUnauthorized: false } });
139
145
  res = await undiciFetch(url, { ...fetchInit, dispatcher } as any) as unknown as Response;
140
146
  } else {
141
147
  res = await fetch(url, fetchInit);
@@ -76,6 +76,19 @@ The WebSocket dropped (system suspend, network blip). Forge auto-reconnects afte
76
76
  gh auth login
77
77
  ```
78
78
 
79
+ ### Connector Test fails with `ENETUNREACH` behind a corp proxy
80
+ Symptom: a brand-new deployment behind a corporate proxy/ZTNA, configure a
81
+ connector (e.g. GitLab) → **Test** → `request failed: fetch failed: connect
82
+ ENETUNREACH <backend-ip>:443`.
83
+
84
+ Forge honours `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` automatically — every
85
+ entry point pre-installs an `EnvHttpProxyAgent` before any code makes a request,
86
+ so a proxy set in the environment is used for all connector calls (including
87
+ `verify_tls: false` self-signed appliances like FortiNAC). Just make sure the
88
+ proxy env vars are exported for the Forge process (in Docker, via the
89
+ container's environment). Off-corp deployments with no proxy env are unaffected
90
+ — requests go direct as before.
91
+
79
92
  ### Skills not syncing
80
93
  Click "Sync" in Skills tab. Check `skillsRepoUrl` in Settings points to valid registry.
81
94
 
@@ -18,6 +18,8 @@
18
18
  * next sub-task still runs in the same cycle.
19
19
  */
20
20
 
21
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
22
+
21
23
  const MEMORY_TICK_SEC = 120;
22
24
 
23
25
  export interface MemorySubTask {
package/lib/projects.ts CHANGED
@@ -297,11 +297,12 @@ export interface StrictResolveResult {
297
297
  * - else gitlab connector default_project_path
298
298
  * - else → error('no_repo_specified')
299
299
  * 3. Re-scan locally for a checkout whose origin matches the target → use it
300
- * 4. Clone via tryGitlabClone — writes to the Forge-managed cache
301
- * (<dataDir>/cloned-projects), which is ALWAYS writable and does NOT
302
- * need a configured projectRoot. We best-effort register the container
303
- * root (/data/project) so manually-placed checkouts there get scanned,
304
- * but never block the clone on it.
300
+ * 4. Clone via tryGitlabClone — lands in a real project root so the
301
+ * checkout persists and is reused next run. In container mode we register
302
+ * the docker-internal /data/project (a host-mounted path) as the root
303
+ * first, so the clone shows up as a normal project; on a host install it
304
+ * takes the first configured project path (projectRoots[0]), falling back
305
+ * to ~/IdeaProjects / <dataDir>/scratch.
305
306
  * - gitlab connector missing → error('no_repo_specified')
306
307
  * - clone failed → error('clone_failed')
307
308
  */
@@ -309,25 +310,30 @@ export function resolveProjectStrict(name: string | undefined): StrictResolveRes
309
310
  const trimmed = (name || '').trim();
310
311
 
311
312
  // Step 1: existing match — reuse the lenient resolver's match logic by
312
- // delegating, but discard its scratch fallback.
313
+ // delegating, but discard its scratch fallback. Crucially, only accept a
314
+ // GIT-BACKED match: this resolver exists for requires_real_project pipelines,
315
+ // so a non-git project (e.g. the built-in 'default' / 'scratch', or a plain
316
+ // folder the chat agent named) must NOT satisfy it — fall through and clone
317
+ // the real repo instead. Without this, `project: default` was returned as-is
318
+ // and the node's `git rev-parse` then failed with "not a git repository".
313
319
  if (trimmed) {
314
320
  const hit = getProjectInfo(trimmed);
315
- if (hit) return { project: hit, source: 'existing' };
321
+ if (hit?.hasGit) return { project: hit, source: 'existing' };
316
322
 
317
323
  const projects = scanProjects();
318
324
  if (trimmed.includes('/')) {
319
325
  const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
320
326
  const wantBase = want.split('/').pop()!;
321
327
  const byRepo = projects.filter((p) => p.repo && p.repo === want);
322
- if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
328
+ if (byRepo.length === 1 && byRepo[0].hasGit) return { project: byRepo[0], source: 'existing' };
323
329
  const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
324
- if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
330
+ if (byRepoBase.length === 1 && byRepoBase[0].hasGit) return { project: byRepoBase[0], source: 'existing' };
325
331
  }
326
332
  const base = trimmed.split('/').pop()!.trim();
327
333
  const ownerRepo = trimmed.replace(/\//g, '-');
328
334
  const cands = projects.filter((p) =>
329
335
  p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
330
- if (cands.length === 1) return { project: cands[0], source: 'existing' };
336
+ if (cands.length === 1 && cands[0].hasGit) return { project: cands[0], source: 'existing' };
331
337
  }
332
338
 
333
339
  // Step 2: derive target repo path
@@ -450,9 +456,17 @@ function tryGitlabClone(repoPath: string): LocalProject | null {
450
456
  const path = repoPath.replace(/^\/+|\/+$/g, '');
451
457
  if (!path || !path.includes('/')) return null; // not a repo path shape
452
458
  const safe = path.replace(/[^A-Za-z0-9._-]+/g, '-');
453
- const cacheRoot = join(getDataDir(), 'cloned-projects');
454
- const target = join(cacheRoot, safe);
455
- try { mkdirSync(cacheRoot, { recursive: true }); } catch {}
459
+ // Clone into a real project root so the checkout persists as a normal
460
+ // project and is reused across runs (no delete needed). In container mode
461
+ // ensureContainerRoot registers the docker-internal /data/project (a
462
+ // host-mounted path) as the first root; on a host install we just take the
463
+ // first configured project path. getDefaultCloneRoot encodes that priority
464
+ // (projectRoots[0] → ~/IdeaProjects → <dataDir>/scratch). The old
465
+ // <dataDir>/cloned-projects hidden cache is retired.
466
+ ensureContainerRoot();
467
+ const cloneRoot = getDefaultCloneRoot();
468
+ const target = join(cloneRoot, safe);
469
+ try { mkdirSync(cloneRoot, { recursive: true }); } catch {}
456
470
 
457
471
  // Inject PAT into URL. gitlab accepts `oauth2:<token>` basic auth on
458
472
  // HTTPS. URL-encode the token in case it contains '@' or '/'.
@@ -467,6 +481,11 @@ function tryGitlabClone(repoPath: string): LocalProject | null {
467
481
  if (!alreadyCloned) {
468
482
  execSync(`git clone --quiet "${authedUrl}" "${target}"`, { stdio: 'pipe', timeout: 120000 });
469
483
  _cloneFreshAt.set(target, Date.now());
484
+ // The new checkout must be discoverable right away: a scanProjects() cache
485
+ // populated earlier this run (e.g. by resolveProjectStrict's pre-clone
486
+ // scan) wouldn't include it, so getProjectInfo would miss the repo we
487
+ // just cloned and downstream resolution could fall back to scratch.
488
+ invalidateProjectScan();
470
489
  } else if (!stillFresh) {
471
490
  // Best-effort refresh; failures (offline, network, auth drift) are
472
491
  // non-fatal — fall back to whatever's cached. Pipelines see stale
@@ -487,9 +506,13 @@ function tryGitlabClone(repoPath: string): LocalProject | null {
487
506
  try { mtime = statSync(target).mtime.toISOString(); }
488
507
  catch { mtime = new Date().toISOString(); }
489
508
  return {
490
- name: path,
509
+ // Use the on-disk dir name (== scanProjects' entry.name) so the name we
510
+ // seed back into input.project matches what getProjectInfo will find next
511
+ // scan. Returning the slashed repo path here desynced the two and made
512
+ // computePipelineTmpDir's getProjectInfo miss → tmp_dir fell to scratch.
513
+ name: safe,
491
514
  path: target,
492
- root: cacheRoot,
515
+ root: cloneRoot,
493
516
  hasGit: existsSync(join(target, '.git')),
494
517
  hasClaudeMd: existsSync(join(target, 'CLAUDE.md')),
495
518
  language: null,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pre-install an EnvHttpProxyAgent on undici's global dispatcher BEFORE
3
+ * anything dynamic-imports the npm undici (8.x).
4
+ *
5
+ * Why: Node honours NODE_USE_ENV_PROXY by putting EnvHttpProxyAgent on
6
+ * undici v1's dispatcher symbol. The npm undici uses v2; on load its
7
+ * `if (getGlobalDispatcher() === undefined) setGlobalDispatcher(new Agent())`
8
+ * reads v2 (undefined) and writes a plain Agent into BOTH v1 and v2,
9
+ * silently clobbering Node's proxy agent — after which every fetch ignores
10
+ * HTTPS_PROXY and direct-connects (ENETUNREACH behind corp ZTNA).
11
+ * Setting v2 here makes that check non-undefined, so npm undici skips it.
12
+ *
13
+ * MUST be the FIRST import in every entry point. Off-corp (no proxy env)
14
+ * this is a no-op, so non-docker / standalone deployments are unchanged.
15
+ */
16
+ import { setGlobalDispatcher, EnvHttpProxyAgent } from 'undici';
17
+
18
+ if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY ||
19
+ process.env.https_proxy || process.env.http_proxy) {
20
+ setGlobalDispatcher(new EnvHttpProxyAgent());
21
+ }
@@ -4,6 +4,7 @@
4
4
  * Runs as a single process — no duplication from Next.js workers.
5
5
  */
6
6
 
7
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
7
8
  import { loadSettings } from './settings';
8
9
 
9
10
  const settings = loadSettings();
@@ -24,6 +24,7 @@
24
24
  * Usage: npx tsx lib/terminal-standalone.ts
25
25
  */
26
26
 
27
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
27
28
  import { WebSocketServer, WebSocket } from 'ws';
28
29
  import * as pty from 'node-pty';
29
30
  import { execSync } from 'node:child_process';
@@ -12,6 +12,7 @@
12
12
  * FORGE_DATA_DIR — data directory
13
13
  */
14
14
 
15
+ import './proxy-setup'; // MUST be first — see proxy-setup.ts header.
15
16
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
16
17
  import { readdirSync, statSync } from 'node:fs';
17
18
  import { join, resolve } from 'node:path';
package/next.config.ts CHANGED
@@ -10,6 +10,13 @@ const localIPs = Object.values(networkInterfaces())
10
10
  .map(i => i!.address);
11
11
 
12
12
  const nextConfig: NextConfig = {
13
+ // Skip `next build`'s TypeScript step. It duplicates our authoritative
14
+ // `tsc --noEmit` (run in CI / publish.sh) and additionally enforces a
15
+ // route-export shape rule that rejects Forge's intentional patterns —
16
+ // craft-system runtime routes that serve React/jsx as importable modules
17
+ // (app/api/craft-system/runtime/*) export non-handler symbols on purpose.
18
+ // tsc remains the real type gate; this only drops Next's redundant check.
19
+ typescript: { ignoreBuildErrors: true },
13
20
  serverExternalPackages: ['better-sqlite3', 'esbuild'],
14
21
  allowedDevOrigins: localIPs,
15
22
  async rewrites() {
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.87",
3
+ "version": "0.10.89",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
6
9
  "scripts": {
7
10
  "dev": "next dev --turbopack",
8
11
  "build": "next build",
package/proxy.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { auth } from './lib/auth';
3
3
  import { hasValidApiKey } from './lib/api-auth';
4
- import { isValidToken } from './app/api/auth/verify/route';
4
+ import { isValidToken } from './lib/api-tokens';
5
5
 
6
6
  // Node runtime: the auth() wrapper decodes the NextAuth session JWT with the
7
7
  // AUTH_SECRET that the route layer also uses (auto-generated in-process when
package/publish.sh CHANGED
@@ -46,6 +46,25 @@ fi
46
46
  echo "Version: $CURRENT → $NEW_VERSION"
47
47
  echo ""
48
48
 
49
+ # ── Build gate: a broken build must never ship ──────────────────────────────
50
+ # publish.sh used to bump+tag+push with ZERO build verification, so compile /
51
+ # bundler errors only surfaced when a fresh user ran `forge server start` (their
52
+ # first clean `next build`). Run it here with a CLEAN .next so a stale cache
53
+ # can't mask source errors. tsc is the authoritative type gate (next build's own
54
+ # TS step is off via typescript.ignoreBuildErrors); __tests__ are tsx scripts
55
+ # checked separately, so they're excluded.
56
+ echo "Verifying clean build before publish ..."
57
+ rm -rf .next
58
+ npx next build --webpack
59
+ TSC_ERRORS=$(npx tsc --noEmit 2>&1 | grep "error TS" | grep -v "__tests__" || true)
60
+ if [ -n "$TSC_ERRORS" ]; then
61
+ echo "$TSC_ERRORS"
62
+ echo "✗ Type errors — aborting publish (no version bump/commit/tag made)."
63
+ exit 1
64
+ fi
65
+ echo "✓ Build + type-check clean"
66
+ echo ""
67
+
49
68
  # Refresh the bundled model-registry snapshot from public-info.
50
69
  # This is the offline/first-run fallback for lib/agents/known-models.ts —
51
70
  # pulling it here keeps it from drifting against the live registry.