showpane 0.4.10 → 0.4.11

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 (47) hide show
  1. package/bundle/meta/scaffold-manifest.json +15 -22
  2. package/bundle/scaffold/__dot__env.example +3 -5
  3. package/bundle/scaffold/package.json +2 -1
  4. package/bundle/scaffold/prisma/schema.local.prisma +1 -1
  5. package/bundle/scaffold/prisma/seed.ts +6 -1
  6. package/bundle/scaffold/prisma.config.ts +5 -0
  7. package/bundle/scaffold/scripts/prisma-schema.mjs +1 -53
  8. package/bundle/scaffold/src/__tests__/client-portals.test.ts +4 -37
  9. package/bundle/scaffold/src/__tests__/deploy-bundle.test.ts +48 -0
  10. package/bundle/scaffold/src/app/api/client-auth/route.ts +1 -1
  11. package/bundle/scaffold/src/lib/client-portals.ts +8 -13
  12. package/bundle/scaffold/src/lib/deploy-bundle.ts +106 -0
  13. package/bundle/scaffold/src/lib/portal-contracts.ts +33 -0
  14. package/bundle/scaffold/src/lib/runtime-state.ts +2 -32
  15. package/bundle/scaffold/src/types/adm-zip.d.ts +15 -0
  16. package/bundle/toolchain/bin/create-deploy-bundle.ts +3 -72
  17. package/bundle/toolchain/bin/ensure-cloud-project-link.ts +73 -0
  18. package/bundle/toolchain/skills/portal-analytics/SKILL.md +2 -2
  19. package/bundle/toolchain/skills/portal-create/SKILL.md +2 -2
  20. package/bundle/toolchain/skills/portal-credentials/SKILL.md +3 -3
  21. package/bundle/toolchain/skills/portal-delete/SKILL.md +4 -4
  22. package/bundle/toolchain/skills/portal-deploy/SKILL.md +32 -264
  23. package/bundle/toolchain/skills/portal-dev/SKILL.md +15 -13
  24. package/bundle/toolchain/skills/portal-list/SKILL.md +2 -2
  25. package/bundle/toolchain/skills/portal-onboard/SKILL.md +2 -2
  26. package/bundle/toolchain/skills/portal-preview/SKILL.md +9 -23
  27. package/bundle/toolchain/skills/portal-setup/SKILL.md +19 -28
  28. package/bundle/toolchain/skills/portal-share/SKILL.md +3 -4
  29. package/bundle/toolchain/skills/portal-status/SKILL.md +2 -2
  30. package/bundle/toolchain/skills/portal-update/SKILL.md +2 -2
  31. package/bundle/toolchain/skills/portal-upgrade/SKILL.md +2 -2
  32. package/bundle/toolchain/skills/portal-verify/SKILL.md +21 -33
  33. package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +1 -5
  34. package/bundle/toolchain/skills/shared/preamble.md +2 -2
  35. package/dist/index.js +55 -9
  36. package/package.json +1 -1
  37. package/bundle/scaffold/docker/Caddyfile +0 -3
  38. package/bundle/scaffold/docker/Dockerfile +0 -30
  39. package/bundle/scaffold/docker-compose.yml +0 -53
  40. package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +0 -143
  41. package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +0 -6
  42. package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +0 -2
  43. package/bundle/scaffold/prisma/migrations/migration_lock.toml +0 -3
  44. package/bundle/scaffold/prisma/schema.prisma +0 -128
  45. package/bundle/scaffold/scripts/backup.sh +0 -19
  46. package/bundle/scaffold/scripts/e2e-verify.sh +0 -487
  47. package/bundle/scaffold/scripts/restore.sh +0 -31
@@ -1,35 +1,26 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-04-09T15:26:39.983Z",
3
+ "generatedAt": "2026-04-09T22:32:17.805Z",
4
4
  "scaffoldVersion": "0.2.4",
5
5
  "files": {
6
- ".env.example": "0dd692f1c7e6bcabdf5dbdfe9abb73797d79d8e90da150d6098b63ddc695dc29",
6
+ ".env.example": "ed105f2bdcd1888a98181d55e3c9f7d6eff3ae9c3e2366c2e777a12e3caddfa7",
7
7
  ".gitignore": "998e5f43865ea56ac79a05acfd5d4b0d696f310bd5325a1ed458c3d40154d437",
8
8
  "VERSION": "1725bfa6524c5265e7c171cf06568417d39b947fff49c242f03859479c82334b",
9
- "docker-compose.yml": "420fd123da019c22f03662933537e24779b4c2c91f90c23abfec5965cd0f35ce",
10
- "docker/Caddyfile": "d9c58086986795f5b3e42ff9b5942e60b8df946a1a0c40351381616c0b4d2bed",
11
- "docker/Dockerfile": "340470e3735ea53b2c03003a13a91361652291add33c40a2bf13e6af2a8cb73a",
12
9
  "next.config.ts": "cf27999cc274cce79bc4c8df11789807719abf40752b60e4b4967a3d2f0ed013",
13
10
  "package-lock.json": "d8e30eb86f08e70787d4459a084b4ab2a9f119696bbd3146ec4ba5675fffd3c2",
14
- "package.json": "f5ee82c0447ee17c3bd4094f8c5c83b6d7f173b407458908d453e94664e6f41b",
11
+ "package.json": "b095e17e7fc181c630e87fe9f473c5a4ef969afcd4b110f9f9c6d6a6d93f1c0b",
15
12
  "postcss.config.js": "fa650b380adfabb151a0b352f7135e107e6352345f899060f1c5c231228f94bf",
16
- "prisma/migrations/20260408000000_init/migration.sql": "a637c252920b45c7dd3e8225c939da373bbaeeccc6f208f9e52c3ff0133a3438",
17
- "prisma/migrations/20260408010000_add_visitor_tracking/migration.sql": "a4432b5d037f069e97ed1765a5bf5a4f3b502e29af1a05406b2aa42bde735e28",
18
- "prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql": "bbfad517dea1257f40e5a0d7807677452fb893a34afbc6d81a3d2bbabc5932af",
19
- "prisma/migrations/migration_lock.toml": "99836963713b4f5b269ad49af0ed3d7b0b2e336115c2f92dc9ac683d139d0900",
20
- "prisma/schema.local.prisma": "4b36b52ef0ccfd5980f05ab97e821001a0f4bb658f13d693a6e3422233399eb8",
21
- "prisma/schema.prisma": "904e517e8956e1b61534d18db6a27c104008ec1d57d474e22e67946c9a1f60ca",
22
- "prisma/seed.ts": "1dea3de4b36263e6172817508e113ba416fbcd8caff80bc190f35cd3b4b3e2c5",
13
+ "prisma.config.ts": "36f56fd74eae70632e484443e38d08665158d72d5c978dc456651d8d5e1a636e",
14
+ "prisma/schema.local.prisma": "f5d6f3cb17d6d229f46ef82eef7c0ff4261596924f0173fef075ac394f423073",
15
+ "prisma/seed.ts": "398b645c31ea0d5b0291f27c32aded22bdb64021e581a547e85b3cccca65c551",
23
16
  "public/example-avatar.svg": "0edeb0d3fbefa89cc27ffe6564d20e3ee0fd073cb6d9f2a025248ef3b3f277fd",
24
17
  "public/example-logo.svg": "bc5cd933aff2a17698dee66a7b4ea940ad12238e9d813474d643b459b1e8d6da",
25
18
  "public/robots.txt": "331ea9090db0c9f6f597bd9840fd5b171830f6e0b3ba1cb24dfa91f0c95aedc1",
26
- "scripts/backup.sh": "7d94758c5f1b0d3a722ec8a1a0b819f1a5f480b762790cf4eeccdd6fda46d11b",
27
- "scripts/e2e-verify.sh": "48c5983b8f2facc228bb4ea1ac0998064268f0fdb0088208dcb12bb1e48c3244",
28
19
  "scripts/prisma-db-push.mjs": "76ac85fe65b5dc3d9cc7432e44618fcc84b7443574c8d88198d01f13ac23c040",
29
20
  "scripts/prisma-generate.mjs": "d371e63388fa39f963b7c3c7cb8f87e0d9cd43cbf69d254b999108e29b8738c8",
30
- "scripts/prisma-schema.mjs": "4c502f640a24558b8000613a06cc956b2253849e3cd8e71849a48db9a94ed435",
31
- "scripts/restore.sh": "4c1be4f0d19aa4572842981e818684349dacbb4ae319ad75ef2b03ee63f0cf98",
32
- "src/__tests__/client-portals.test.ts": "c6999300e9289333c72cc4493dd13a2b344794839ee76232fc9ebdd08f021b89",
21
+ "scripts/prisma-schema.mjs": "0a86cc1b5f84120948aed8f97a84f2d5b173f91a43ea34ad6767441894121d83",
22
+ "src/__tests__/client-portals.test.ts": "fe8e491e62fb2a84de52cdc1154d1451083f93bbccf1c5e65b42810d007eecc2",
23
+ "src/__tests__/deploy-bundle.test.ts": "abd3216170f306c09df6abb0d2afad966a5741e8859f25a310a0a09693d37609",
33
24
  "src/__tests__/portal-contracts.test.ts": "80066377d3281786c2bb9ecc857514124e094a2e66dca2fb08ded994c25fa2bc",
34
25
  "src/app/(portal)/client/[slug]/page.tsx": "4f2f9253b2ad5d37a0f13759db52c786ae9c401f50fae9431da1417e9736e000",
35
26
  "src/app/(portal)/client/[slug]/s/[token]/route.ts": "a445e54b9139e40dfe0bc039e34e6af224a27f75a614741ab224d317ad4d3ec9",
@@ -37,7 +28,7 @@
37
28
  "src/app/(portal)/client/example/page.tsx": "f330864f63c9feea76c8a62c3eba3ce57578627e0d4abd929fd7fefdfc7af058",
38
29
  "src/app/(portal)/client/layout.tsx": "4f43871510408a81da229d48ae316ec1d1c1beda93121922246300a2c8fd0999",
39
30
  "src/app/(portal)/client/page.tsx": "af36f1a6f359d6a7bd4a6ac550058c9d9c107e9885bb238b4c06ec26700e23e3",
40
- "src/app/api/client-auth/route.ts": "46508351fce1902c1878efce86b5d4ebdd28a4977941100fdc77a48d79c5d765",
31
+ "src/app/api/client-auth/route.ts": "ce1858559b1e944d5b1dc719d1f03bebf66286671700b1b5397382109f0f1e0d",
41
32
  "src/app/api/client-auth/share/route.ts": "ed82414212dcd26af8c6c0f2bd44d9d79a727ed35cfedbac8c4077a6220aad14",
42
33
  "src/app/api/client-events/route.ts": "13d545537b7e8ce421e6169d25c105adf2a2de3d978ae0a2c6751ff5f7d2eb33",
43
34
  "src/app/api/client-files/[...path]/route.ts": "d6279a8eab48aa82d07ac8d467276968898c0d426eeec0a7d1fba1fa754ef405",
@@ -54,18 +45,20 @@
54
45
  "src/lib/abuse-controls.ts": "d79d58d93267aca48ad0b7b9b91f753c9a3c27263e4e98daf768a950c44a6fc6",
55
46
  "src/lib/branding.ts": "cc55f40e02bc3e486b227988f95739ca1cda8012c97b591295995eb4465efd57",
56
47
  "src/lib/client-auth.ts": "b9bdfe77dbe5d6ec6c6a930627fc43d3253f0d76fd8fc4093af5a75742bebe42",
57
- "src/lib/client-portals.ts": "0a2c1ed8890bee35cd5f582554ede7da8a51466aec052395e44810198e6d48e2",
48
+ "src/lib/client-portals.ts": "9b531f9a9ea459b4ab85257b9dd282874fa1422838fe89d511940e417114216a",
58
49
  "src/lib/control-plane.ts": "e0cf39f28ec7de715fd5cfbb5f4240773fcd3d775cd1677588dd749fff740a0e",
59
50
  "src/lib/db.ts": "65fb87fde5a05fa033234f3a976f6730d99fab7982a83821c792d09fc659e439",
51
+ "src/lib/deploy-bundle.ts": "e9675cccb2c802e408639481986c6b629737541853e1c93f322c08a5b9dfc5f9",
60
52
  "src/lib/files.ts": "24fd8d1d53c180d62441019395fb140ba3baa28311918ac488284adcdda8eb9a",
61
53
  "src/lib/load-app-env.ts": "78b80e17d896885f0d72315ee9a6cf7a0a8c6c08171f26e3d599bb9b2e8afeee",
62
- "src/lib/portal-contracts.ts": "5e9f5c747eb911a056bb5626d0710c37dbc8aba02a8e054203f736d13511bef7",
54
+ "src/lib/portal-contracts.ts": "519a97afe3ae618077c95aba5a9764383e7edf6c9effd62a5638a50a0b2a676a",
63
55
  "src/lib/prisma-client.ts": "28cd100129a0178a6c8fdfe49e6997b19983fcc427b9fa7caee3ac26226e5eb3",
64
- "src/lib/runtime-state.ts": "2bbd9c2d9e963e544a52ac04cc7c5651558cc8b7efd45e179b4eb72d8a640122",
56
+ "src/lib/runtime-state.ts": "3d30de7dfeaaa48d8b6fd5d29976ecd001408172100c95b063d5d804fdce0a2e",
65
57
  "src/lib/storage.ts": "ae3b85fc6cccd39d4174a391dcbe6e91fb9460eb407ec9dbfedd63594a441d08",
66
58
  "src/lib/token.ts": "518898ca3cbba069f507f736ed80f5afa0c5af07b0b02fdd2d682e466598c803",
67
59
  "src/lib/utils.ts": "d1f1e0d62cb8d8d1e04c26e14de842d8a151f75812d81b046c65b5d1fe8e4b27",
68
60
  "src/middleware.ts": "5623ef170d9327e47373943eae33a8b07a6dda970525d0833e0865e4429df094",
61
+ "src/types/adm-zip.d.ts": "a9a32ea84d6d6cd89626ba5cd6f5519158a652362abbe5647474114c30ecc3c4",
69
62
  "tailwind.config.ts": "dc0dec22249b290b857190495ce7140327a6f4d94fedd5dffcdb298fa3928071",
70
63
  "tests/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
71
64
  "tsconfig.json": "1b12f56ab16430b64622ff8512cb05b06cf633060dbec6ebad6f9e792a6dc2a0",
@@ -1,11 +1,7 @@
1
1
  # Database
2
2
  # Local development (SQLite, zero config):
3
+ # Prisma commands auto-select the matching schema from this value.
3
4
  DATABASE_URL="file:./dev.db"
4
- # Production (PostgreSQL):
5
- # DATABASE_URL="postgresql://portal:change-me-in-.env@db:5432/portal"
6
- # POSTGRES_USER="portal"
7
- # POSTGRES_PASSWORD="change-me-in-.env"
8
- # POSTGRES_DB="portal"
9
5
 
10
6
  # Auth — generate with: openssl rand -base64 32
11
7
  AUTH_SECRET=""
@@ -22,3 +18,5 @@ STORAGE_PROVIDER="local"
22
18
 
23
19
  # Public URL (used for share links)
24
20
  # NEXT_PUBLIC_APP_URL="https://portal.example.com"
21
+
22
+ # Showpane Cloud publish builds a prebuilt artifact locally during /portal deploy.
@@ -2,14 +2,15 @@
2
2
  "name": "showpane-portal",
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
+ "packageManager": "npm@10.9.2",
5
6
  "scripts": {
6
7
  "dev": "next dev",
7
8
  "prisma:generate": "node scripts/prisma-generate.mjs",
8
9
  "prisma:db-push": "node scripts/prisma-db-push.mjs",
10
+ "cloud:build": "npm run prisma:generate && npx --yes vercel build --prod",
9
11
  "build": "npm run prisma:generate && next build",
10
12
  "start": "next start",
11
13
  "postinstall": "npm run prisma:generate",
12
- "db:migrate": "prisma migrate deploy",
13
14
  "db:push": "npm run prisma:db-push",
14
15
  "db:seed": "npx tsx prisma/seed.ts",
15
16
  "test": "vitest"
@@ -1,5 +1,5 @@
1
1
  // Local development schema — SQLite provider for zero-config local dev.
2
- // Used by `npx showpane` installer via: prisma db push --schema prisma/schema.local.prisma
2
+ // Canonical Prisma schema for the Showpane app.
3
3
 
4
4
  generator client {
5
5
  provider = "prisma-client"
@@ -27,7 +27,12 @@ async function main() {
27
27
  slug: "example",
28
28
  },
29
29
  },
30
- update: {},
30
+ update: {
31
+ companyName: "Acme Health",
32
+ username: "example",
33
+ passwordHash,
34
+ lastUpdated: "2 April 2026",
35
+ },
31
36
  create: {
32
37
  organizationId: org.id,
33
38
  slug: "example",
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "prisma/config";
2
+
3
+ export default defineConfig({
4
+ schema: "./prisma/schema.local.prisma",
5
+ });
@@ -1,64 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import fs from "node:fs";
3
2
  import path from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
4
 
6
5
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
7
6
  const appRoot = path.resolve(scriptDir, "..");
8
7
 
9
- function parseEnvValue(rawValue) {
10
- const value = rawValue.trim();
11
- if (
12
- (value.startsWith("\"") && value.endsWith("\"")) ||
13
- (value.startsWith("'") && value.endsWith("'"))
14
- ) {
15
- return value.slice(1, -1);
16
- }
17
- return value;
18
- }
19
-
20
- function parseEnvFile(filePath) {
21
- if (!fs.existsSync(filePath)) return {};
22
-
23
- const env = {};
24
- for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
25
- const trimmed = line.trim();
26
- if (!trimmed || trimmed.startsWith("#")) continue;
27
-
28
- const separator = trimmed.indexOf("=");
29
- if (separator === -1) continue;
30
-
31
- const key = trimmed.slice(0, separator).trim();
32
- const value = trimmed.slice(separator + 1);
33
- env[key] = parseEnvValue(value);
34
- }
35
-
36
- return env;
37
- }
38
-
39
- export function getDatabaseUrl() {
40
- if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
41
-
42
- const envPaths = [
43
- path.join(appRoot, ".env"),
44
- path.join(appRoot, ".env.local"),
45
- ];
46
-
47
- const merged = {};
48
- for (const envPath of envPaths) {
49
- Object.assign(merged, parseEnvFile(envPath));
50
- }
51
-
52
- return merged.DATABASE_URL ?? null;
53
- }
54
-
55
8
  export function getSchemaPath() {
56
- const databaseUrl = getDatabaseUrl();
57
- const schemaName = databaseUrl?.startsWith("file:")
58
- ? "schema.local.prisma"
59
- : "schema.prisma";
60
-
61
- return path.join(appRoot, "prisma", schemaName);
9
+ return path.join(appRoot, "prisma", "schema.local.prisma");
62
10
  }
63
11
 
64
12
  export function runPrismaCommand(args) {
@@ -21,56 +21,23 @@ const mockedPrisma = vi.mocked(prisma);
21
21
  describe("resolveDefaultOrganizationId", () => {
22
22
  beforeEach(() => {
23
23
  vi.resetAllMocks();
24
- delete process.env.ORG_ID;
25
24
  });
26
25
 
27
- it("returns org id from ORG_ID env var when set and org exists", async () => {
28
- process.env.ORG_ID = "cloud-org-123";
29
- mockedPrisma.organization.findUnique.mockResolvedValue({
30
- id: "cloud-org-123",
31
- } as never);
32
-
33
- const result = await resolveDefaultOrganizationId();
34
-
35
- expect(result).toBe("cloud-org-123");
36
- expect(mockedPrisma.organization.findUnique).toHaveBeenCalledWith({
37
- where: { id: "cloud-org-123" },
38
- select: { id: true },
39
- });
40
- expect(mockedPrisma.organization.findFirst).not.toHaveBeenCalled();
41
- });
42
-
43
- it("returns null when ORG_ID is set but org not in DB", async () => {
44
- process.env.ORG_ID = "nonexistent-org";
45
- mockedPrisma.organization.findUnique.mockResolvedValue(null);
46
-
47
- const result = await resolveDefaultOrganizationId();
48
-
49
- expect(result).toBeNull();
50
- expect(mockedPrisma.organization.findUnique).toHaveBeenCalledWith({
51
- where: { id: "nonexistent-org" },
52
- select: { id: true },
53
- });
54
- expect(mockedPrisma.organization.findFirst).not.toHaveBeenCalled();
55
- });
56
-
57
- it("falls back to first org in DB when ORG_ID not set", async () => {
58
- // ORG_ID is not set (deleted in beforeEach)
26
+ it("falls back to first org in DB", async () => {
59
27
  mockedPrisma.organization.findFirst.mockResolvedValue({
60
- id: "self-hosted-org-1",
28
+ id: "local-org-1",
61
29
  } as never);
62
30
 
63
31
  const result = await resolveDefaultOrganizationId();
64
32
 
65
- expect(result).toBe("self-hosted-org-1");
33
+ expect(result).toBe("local-org-1");
66
34
  expect(mockedPrisma.organization.findFirst).toHaveBeenCalledWith({
67
35
  select: { id: true },
68
36
  orderBy: { createdAt: "asc" },
69
37
  });
70
- expect(mockedPrisma.organization.findUnique).not.toHaveBeenCalled();
71
38
  });
72
39
 
73
- it("returns null when ORG_ID not set and no orgs in DB", async () => {
40
+ it("returns null when no orgs exist in DB", async () => {
74
41
  mockedPrisma.organization.findFirst.mockResolvedValue(null);
75
42
 
76
43
  const result = await resolveDefaultOrganizationId();
@@ -0,0 +1,48 @@
1
+ import AdmZip from "adm-zip";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ mkdtempSync,
5
+ mkdirSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { createDeployBundle } from "@/lib/deploy-bundle";
13
+
14
+ const tempDirs: string[] = [];
15
+
16
+ afterEach(() => {
17
+ for (const dir of tempDirs.splice(0)) {
18
+ rmSync(dir, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ describe("createDeployBundle", () => {
23
+ it("materializes symlinked function aliases into the artifact", () => {
24
+ const appRoot = mkdtempSync(path.join(os.tmpdir(), "showpane-bundle-"));
25
+ tempDirs.push(appRoot);
26
+
27
+ const outputRoot = path.join(appRoot, ".vercel", "output");
28
+ const realFuncDir = path.join(outputRoot, "functions", "real.func");
29
+ mkdirSync(realFuncDir, { recursive: true });
30
+ writeFileSync(path.join(outputRoot, "config.json"), JSON.stringify({ version: 3 }));
31
+ writeFileSync(path.join(realFuncDir, ".vc-config.json"), JSON.stringify({ handler: "index.js" }));
32
+ writeFileSync(path.join(realFuncDir, "index.js"), "module.exports = {};\n");
33
+
34
+ const aliasDir = path.join(outputRoot, "functions", "alias.func");
35
+ symlinkSync("real.func", aliasDir);
36
+
37
+ const artifactPath = path.join(appRoot, "artifact.zip");
38
+ const result = createDeployBundle(appRoot, artifactPath);
39
+ expect(result.fileCount).toBe(5);
40
+
41
+ const names = new Set(new AdmZip(artifactPath).getEntries().map((entry) => entry.entryName));
42
+ expect(names.has(".vercel/output/config.json")).toBe(true);
43
+ expect(names.has(".vercel/output/functions/real.func/.vc-config.json")).toBe(true);
44
+ expect(names.has(".vercel/output/functions/real.func/index.js")).toBe(true);
45
+ expect(names.has(".vercel/output/functions/alias.func/.vc-config.json")).toBe(true);
46
+ expect(names.has(".vercel/output/functions/alias.func/index.js")).toBe(true);
47
+ });
48
+ });
@@ -60,7 +60,7 @@ export async function POST(req: NextRequest) {
60
60
  return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
61
61
  }
62
62
 
63
- // Resolve organization — self-hosted uses first org, cloud reads ORG_ID env var
63
+ // Resolve organization — local uses the first org, hosted runtime uses runtime-state
64
64
  const orgId = await resolveDefaultOrganizationId();
65
65
  if (!orgId) {
66
66
  return NextResponse.json({ error: "No organization configured" }, { status: 503 });
@@ -77,6 +77,10 @@ export async function getClientPortalId(
77
77
  organizationId: string,
78
78
  slug: string
79
79
  ): Promise<string | null> {
80
+ if (isRuntimeSnapshotMode()) {
81
+ return null;
82
+ }
83
+
80
84
  const portal = await prisma.clientPortal.findFirst({
81
85
  where: { organizationId, slug, isActive: true },
82
86
  select: { id: true },
@@ -108,24 +112,15 @@ export async function getCredentialVersion(
108
112
 
109
113
  /**
110
114
  * Resolve the organizationId for the current request context.
111
- * Cloud: each Vercel project has ORG_ID set during provisioning.
112
- * Self-hosted: returns the single org in the DB.
115
+ * Hosted runtime reads it from the runtime snapshot.
116
+ * Local workspace returns the first org from the SQLite database.
113
117
  */
114
118
  export async function resolveDefaultOrganizationId(): Promise<string | null> {
115
119
  if (isRuntimeSnapshotMode()) {
116
120
  const state = await getRuntimeState();
117
- return state?.organization.id ?? process.env.ORG_ID ?? null;
118
- }
119
-
120
- // Cloud: each Vercel project has ORG_ID set during provisioning
121
- if (process.env.ORG_ID) {
122
- const org = await prisma.organization.findUnique({
123
- where: { id: process.env.ORG_ID },
124
- select: { id: true },
125
- });
126
- return org?.id ?? null;
121
+ return state?.organization.id ?? null;
127
122
  }
128
- // Self-hosted: use the single org in the DB
123
+ // Local workspace: use the first org in the DB
129
124
  const org = await prisma.organization.findFirst({
130
125
  select: { id: true },
131
126
  orderBy: { createdAt: "asc" },
@@ -0,0 +1,106 @@
1
+ import AdmZip from "adm-zip";
2
+ import {
3
+ lstatSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ statSync,
8
+ } from "node:fs";
9
+ import path from "node:path";
10
+
11
+ function walkFiles(dir: string, root: string, out: Set<string>) {
12
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
13
+ const fullPath = path.join(dir, entry.name);
14
+ if (entry.isDirectory()) {
15
+ walkFiles(fullPath, root, out);
16
+ continue;
17
+ }
18
+ out.add(path.relative(root, fullPath));
19
+ }
20
+ }
21
+
22
+ export function collectTracedFiles(appPath: string): Set<string> {
23
+ const files = new Set<string>();
24
+ const outputRoot = path.join(appPath, ".vercel", "output");
25
+ const functionsRoot = path.join(outputRoot, "functions");
26
+
27
+ walkFiles(outputRoot, appPath, files);
28
+
29
+ const queue = [functionsRoot];
30
+ while (queue.length > 0) {
31
+ const current = queue.pop();
32
+ if (!current) continue;
33
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
34
+ const fullPath = path.join(current, entry.name);
35
+ if (entry.isDirectory()) {
36
+ queue.push(fullPath);
37
+ continue;
38
+ }
39
+ if (entry.name !== ".vc-config.json") continue;
40
+
41
+ const config = JSON.parse(readFileSync(fullPath, "utf8")) as {
42
+ filePathMap?: Record<string, string>;
43
+ };
44
+ for (const relativePath of Object.values(config.filePathMap ?? {})) {
45
+ files.add(relativePath);
46
+ }
47
+ }
48
+ }
49
+
50
+ return files;
51
+ }
52
+
53
+ function addPathToZip(zip: AdmZip, sourcePath: string, zipPath: string): number {
54
+ const zipPathPosix = zipPath.replace(/\\/g, "/");
55
+
56
+ if (zipPathPosix === ".env" || zipPathPosix.startsWith(".env.")) {
57
+ zip.addFile(zipPathPosix, Buffer.from("NODE_ENV=production\n"));
58
+ return 1;
59
+ }
60
+
61
+ const entry = lstatSync(sourcePath, { throwIfNoEntry: false });
62
+ if (!entry) {
63
+ return 0;
64
+ }
65
+
66
+ if (entry.isSymbolicLink()) {
67
+ return addPathToZip(zip, realpathSync(sourcePath), zipPathPosix);
68
+ }
69
+
70
+ if (entry.isDirectory()) {
71
+ let count = 0;
72
+ for (const child of readdirSync(sourcePath, { withFileTypes: true })) {
73
+ count += addPathToZip(
74
+ zip,
75
+ path.join(sourcePath, child.name),
76
+ path.posix.join(zipPathPosix, child.name),
77
+ );
78
+ }
79
+ return count;
80
+ }
81
+
82
+ if (!entry.isFile() && !statSync(sourcePath, { throwIfNoEntry: false })?.isFile()) {
83
+ return 0;
84
+ }
85
+
86
+ zip.addLocalFile(sourcePath, path.posix.dirname(zipPathPosix), path.posix.basename(zipPathPosix));
87
+ return 1;
88
+ }
89
+
90
+ export function createDeployBundle(appPath: string, outputPath: string): { fileCount: number } {
91
+ const outputRoot = path.join(appPath, ".vercel", "output");
92
+ if (!statSync(outputRoot, { throwIfNoEntry: false })?.isDirectory()) {
93
+ throw new Error("Missing .vercel/output. Run `npm run cloud:build` first.");
94
+ }
95
+
96
+ const zip = new AdmZip();
97
+ const tracedFiles = collectTracedFiles(appPath);
98
+ let fileCount = 0;
99
+
100
+ for (const relativePath of tracedFiles) {
101
+ fileCount += addPathToZip(zip, path.join(appPath, relativePath), relativePath);
102
+ }
103
+
104
+ zip.writeZip(outputPath);
105
+ return { fileCount };
106
+ }
@@ -49,6 +49,39 @@ export interface PortalFileSyncManifestPayload {
49
49
  files: PortalFileSyncManifestEntry[];
50
50
  }
51
51
 
52
+ export interface RuntimePortalSnapshot {
53
+ slug: string;
54
+ companyName: string;
55
+ logoUrl?: string | null;
56
+ username: string;
57
+ passwordHash: string;
58
+ credentialVersion: string;
59
+ isActive: boolean;
60
+ lastUpdated?: string | null;
61
+ }
62
+
63
+ export interface RuntimeOrganizationSnapshot {
64
+ id: string;
65
+ slug: string;
66
+ name: string;
67
+ logoUrl?: string | null;
68
+ primaryColor?: string;
69
+ portalLabel?: string;
70
+ websiteUrl?: string | null;
71
+ contactName?: string | null;
72
+ contactTitle?: string | null;
73
+ contactEmail?: string | null;
74
+ contactPhone?: string | null;
75
+ contactAvatar?: string | null;
76
+ supportEmail?: string | null;
77
+ customDomain?: string | null;
78
+ }
79
+
80
+ export interface RuntimeStatePayload {
81
+ organization: RuntimeOrganizationSnapshot;
82
+ portals: RuntimePortalSnapshot[];
83
+ }
84
+
52
85
  const PORTAL_EVENT_TYPE_SET = new Set<string>(PORTAL_EVENT_TYPES);
53
86
 
54
87
  export function isPortalEventType(value: unknown): value is PortalEventType {
@@ -1,38 +1,8 @@
1
1
  import { readFile } from "fs/promises";
2
2
  import path from "path";
3
+ import type { RuntimeStatePayload } from "@/lib/portal-contracts";
3
4
 
4
- export type RuntimePortalSnapshot = {
5
- slug: string;
6
- companyName: string;
7
- logoUrl?: string | null;
8
- username: string;
9
- passwordHash: string;
10
- credentialVersion: string;
11
- isActive: boolean;
12
- lastUpdated?: string | null;
13
- };
14
-
15
- export type RuntimeOrganizationSnapshot = {
16
- id: string;
17
- slug: string;
18
- name: string;
19
- logoUrl?: string | null;
20
- primaryColor?: string;
21
- portalLabel?: string;
22
- websiteUrl?: string | null;
23
- contactName?: string | null;
24
- contactTitle?: string | null;
25
- contactEmail?: string | null;
26
- contactPhone?: string | null;
27
- contactAvatar?: string | null;
28
- supportEmail?: string | null;
29
- customDomain?: string | null;
30
- };
31
-
32
- export type RuntimeState = {
33
- organization: RuntimeOrganizationSnapshot;
34
- portals: RuntimePortalSnapshot[];
35
- };
5
+ export type RuntimeState = RuntimeStatePayload;
36
6
 
37
7
  let cachedState: RuntimeState | null | undefined;
38
8
 
@@ -0,0 +1,15 @@
1
+ declare module "adm-zip" {
2
+ export type AdmZipEntry = {
3
+ entryName: string;
4
+ isDirectory: boolean;
5
+ getData(): Buffer;
6
+ };
7
+
8
+ export default class AdmZip {
9
+ constructor(path?: string);
10
+ addFile(entryName: string, content: Buffer): void;
11
+ addLocalFile(filePath: string, zipPath?: string, zipName?: string): void;
12
+ getEntries(): AdmZipEntry[];
13
+ writeZip(targetFileName: string): void;
14
+ }
15
+ }