@proveanything/smartlinks 2.0.68 → 2.0.70

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.
@@ -587,7 +587,10 @@ Key records to the returned canonical `id`, and refuse `null` — otherwise anyo
587
587
  empty fields** — it never overwrites a name or field the contact already has, because a visitor's typed
588
588
  details are unverified. It's race-safe: two simultaneous entries with one email end on one contact.
589
589
  Returns `{ contactId, created }`. `forCaller()` is the signed-in caller's own contact (`null` when
590
- anonymous). **Never return contact ids to public callers** — keep them on your records.
590
+ anonymous): `{ contactId, email, phone, firstName, lastName, displayName, tags, customFields }`. Use it
591
+ to check what a member is before returning member-only content — a level the staff set
592
+ (`customFields.level`), a tag. It includes staff-only fields, so return only what the caller may see
593
+ (see members.md). **Never return contact ids to public callers** — keep them on your records.
591
594
 
592
595
  This is not consent. A ticked "keep me updated" box is evidence you can record (below); subscribing
593
596
  someone to marketing is the double opt-in flow (`comms.signup`, see comms.md).
package/openapi.yaml CHANGED
@@ -2116,6 +2116,44 @@ paths:
2116
2116
  schema:
2117
2117
  type: object
2118
2118
  additionalProperties: true
2119
+ /admin/collection/{collectionId}/authKit/{authKitId}/invite:
2120
+ post:
2121
+ tags:
2122
+ - authKit
2123
+ summary: Invite someone to be a member of a site with its own logins (admin).
2124
+ operationId: authKit_invite
2125
+ security:
2126
+ - bearerAuth: []
2127
+ parameters:
2128
+ - name: collectionId
2129
+ in: path
2130
+ required: true
2131
+ schema:
2132
+ type: string
2133
+ - name: authKitId
2134
+ in: path
2135
+ required: true
2136
+ schema:
2137
+ type: string
2138
+ responses:
2139
+ 200:
2140
+ description: Success
2141
+ content:
2142
+ application/json:
2143
+ schema:
2144
+ $ref: "#/components/schemas/AuthKitInviteResponse"
2145
+ 400:
2146
+ description: Bad request
2147
+ 401:
2148
+ description: Unauthorized
2149
+ 404:
2150
+ description: Not found
2151
+ requestBody:
2152
+ required: true
2153
+ content:
2154
+ application/json:
2155
+ schema:
2156
+ $ref: "#/components/schemas/AuthKitInviteRequest"
2119
2157
  /admin/collection/{collectionId}/batch:
2120
2158
  get:
2121
2159
  tags:
@@ -9898,10 +9936,7 @@ paths:
9898
9936
  content:
9899
9937
  application/json:
9900
9938
  schema:
9901
- type: string
9902
- enum:
9903
- - email
9904
- - sms
9939
+ $ref: "#/components/schemas/MfaFactor"
9905
9940
  /authkit/{clientId}/mfa/challenge/verify:
9906
9941
  post:
9907
9942
  tags:
@@ -10058,6 +10093,58 @@ paths:
10058
10093
  description: Unauthorized
10059
10094
  404:
10060
10095
  description: Not found
10096
+ /authkit/{clientId}/mfa/factors/totp/confirm:
10097
+ post:
10098
+ tags:
10099
+ - authKit
10100
+ summary: Confirm authenticator-app enrollment with the 6-digit code the app shows (authenticated).
10101
+ operationId: authKit_confirmTotpMfa
10102
+ security: []
10103
+ parameters:
10104
+ - name: clientId
10105
+ in: path
10106
+ required: true
10107
+ schema:
10108
+ type: string
10109
+ responses:
10110
+ 200:
10111
+ description: Success
10112
+ content:
10113
+ application/json:
10114
+ schema:
10115
+ $ref: "#/components/schemas/MfaEnrolledResponse"
10116
+ 400:
10117
+ description: Bad request
10118
+ 401:
10119
+ description: Unauthorized
10120
+ 404:
10121
+ description: Not found
10122
+ /authkit/{clientId}/mfa/factors/totp/enroll:
10123
+ post:
10124
+ tags:
10125
+ - authKit
10126
+ summary: "Begin authenticator-app enrollment (authenticated): a new secret for Google Authenticator, Microsoft Authenticator, 1Password and the like."
10127
+ operationId: authKit_enrollTotpMfa
10128
+ security: []
10129
+ parameters:
10130
+ - name: clientId
10131
+ in: path
10132
+ required: true
10133
+ schema:
10134
+ type: string
10135
+ responses:
10136
+ 200:
10137
+ description: Success
10138
+ content:
10139
+ application/json:
10140
+ schema:
10141
+ $ref: "#/components/schemas/MfaTotpEnrollResponse"
10142
+ 400:
10143
+ description: Bad request
10144
+ 401:
10145
+ description: Unauthorized
10146
+ 404:
10147
+ description: Not found
10061
10148
  /authkit/{clientId}/mfa/factors/{factor}:
10062
10149
  get:
10063
10150
  tags:
@@ -19790,6 +19877,37 @@ components:
19790
19877
  - userId
19791
19878
  - anonymous
19792
19879
  - via
19880
+ CallerContact:
19881
+ type: object
19882
+ properties:
19883
+ contactId:
19884
+ type: string
19885
+ email:
19886
+ type: string
19887
+ phone:
19888
+ type: string
19889
+ firstName:
19890
+ type: string
19891
+ lastName:
19892
+ type: string
19893
+ displayName:
19894
+ type: string
19895
+ tags:
19896
+ type: array
19897
+ items:
19898
+ type: string
19899
+ customFields:
19900
+ type: object
19901
+ additionalProperties: true
19902
+ required:
19903
+ - contactId
19904
+ - email
19905
+ - phone
19906
+ - firstName
19907
+ - lastName
19908
+ - displayName
19909
+ - tags
19910
+ - customFields
19793
19911
  ServerFunctionContext:
19794
19912
  type: object
19795
19913
  properties:
@@ -22894,15 +23012,9 @@ components:
22894
23012
  availableFactors:
22895
23013
  type: array
22896
23014
  items:
22897
- type: string
22898
- enum:
22899
- - email
22900
- - sms
23015
+ $ref: "#/components/schemas/MfaFactor"
22901
23016
  preferredFactor:
22902
- type: string
22903
- enum:
22904
- - email
22905
- - sms
23017
+ $ref: "#/components/schemas/MfaFactor"
22906
23018
  maskedDestinations:
22907
23019
  type: object
22908
23020
  additionalProperties: true
@@ -22918,10 +23030,7 @@ components:
22918
23030
  type: object
22919
23031
  additionalProperties: true
22920
23032
  factor:
22921
- type: string
22922
- enum:
22923
- - email
22924
- - sms
23033
+ $ref: "#/components/schemas/MfaFactor"
22925
23034
  destination:
22926
23035
  type: string
22927
23036
  expiresAt:
@@ -22951,6 +23060,28 @@ components:
22951
23060
  - mfaSessionToken
22952
23061
  - destination
22953
23062
  - expiresAt
23063
+ MfaTotpEnrollResponse:
23064
+ type: object
23065
+ properties:
23066
+ mfaSessionToken:
23067
+ type: string
23068
+ secret:
23069
+ type: string
23070
+ otpauthUri:
23071
+ type: string
23072
+ issuer:
23073
+ type: string
23074
+ account:
23075
+ type: string
23076
+ expiresAt:
23077
+ type: string
23078
+ required:
23079
+ - mfaSessionToken
23080
+ - secret
23081
+ - otpauthUri
23082
+ - issuer
23083
+ - account
23084
+ - expiresAt
22954
23085
  MfaEnrolledResponse:
22955
23086
  type: object
22956
23087
  properties:
@@ -22958,10 +23089,7 @@ components:
22958
23089
  type: object
22959
23090
  additionalProperties: true
22960
23091
  factor:
22961
- type: string
22962
- enum:
22963
- - email
22964
- - sms
23092
+ $ref: "#/components/schemas/MfaFactor"
22965
23093
  required:
22966
23094
  - enrolled
22967
23095
  - factor
@@ -22977,6 +23105,9 @@ components:
22977
23105
  sms:
22978
23106
  type: object
22979
23107
  additionalProperties: true
23108
+ totp:
23109
+ type: object
23110
+ additionalProperties: true
22980
23111
  recoveryCodesRemaining:
22981
23112
  type: number
22982
23113
  mfaEnabledForClient:
@@ -23372,8 +23503,51 @@ components:
23372
23503
  type: string
23373
23504
  identity:
23374
23505
  $ref: "#/components/schemas/AuthKitIdentity"
23506
+ signUp:
23507
+ type: string
23508
+ enum:
23509
+ - open
23510
+ - invite
23375
23511
  required:
23376
23512
  - id
23513
+ AuthKitInviteRequest:
23514
+ type: object
23515
+ properties:
23516
+ email:
23517
+ type: string
23518
+ firstName:
23519
+ type: string
23520
+ lastName:
23521
+ type: string
23522
+ displayName:
23523
+ type: string
23524
+ customFields:
23525
+ type: object
23526
+ additionalProperties: true
23527
+ redirectUrl:
23528
+ type: string
23529
+ required:
23530
+ - email
23531
+ AuthKitInviteResponse:
23532
+ type: object
23533
+ properties:
23534
+ ok:
23535
+ type: object
23536
+ additionalProperties: true
23537
+ userId:
23538
+ type: string
23539
+ contactId:
23540
+ type: string
23541
+ created:
23542
+ type: boolean
23543
+ expiresAt:
23544
+ type: string
23545
+ required:
23546
+ - ok
23547
+ - userId
23548
+ - contactId
23549
+ - created
23550
+ - expiresAt
23377
23551
  AuthKitIdentity:
23378
23552
  type: object
23379
23553
  properties:
@@ -23465,6 +23639,13 @@ components:
23465
23639
  - INVALID_APPLE_TOKEN
23466
23640
  - ACCOUNT_EXISTS_UNVERIFIED
23467
23641
  - APPLE_AUTH_FAILED
23642
+ - SIGN_UP_CLOSED
23643
+ MfaFactor:
23644
+ type: string
23645
+ enum:
23646
+ - email
23647
+ - sms
23648
+ - totp
23468
23649
  MfaErrorCode:
23469
23650
  type: string
23470
23651
  enum:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.0.68",
3
+ "version": "2.0.70",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,147 +1,185 @@
1
- #!/usr/bin/env node
2
- /**
3
- * publish — the FAST dev publish for a SmartLinks app. Builds nothing itself: it takes an already-
4
- * built `dist/`, uploads it to the SmartLinks publish endpoint, which writes it straight to the CDN
5
- * (dev = no-store, instantly fresh) and registers the release. One call = host + register, no Cloud
6
- * Build, no Lovable. Works from anywhere: local, Claude, CI, or a build step.
7
- *
8
- * smartlinks-publish # build dist/ yourself first, then this
9
- * smartlinks-publish --watch # re-publish on file change (save → live)
10
- *
11
- * Env:
12
- * SMARTLINKS_APP_ID the platform app id (required)
13
- * SMARTLINKS_DEPLOY_KEY channel-scoped deploy key (dev key is dev-only, safe to keep locally)
14
- * SMARTLINKS_API API host (default https://smartlinks.app)
15
- * SMARTLINKS_CHANNEL channel (default dev; only dev is supported by this fast path today)
16
- * Flags: --dir <dist> --watch --channel <dev>
17
- */
18
-
19
- import { readFileSync, readdirSync, statSync, watch } from 'node:fs'
20
- import { join, relative, sep } from 'node:path'
21
-
22
- const args = process.argv.slice(2)
23
- const flag = (name, def) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def }
24
-
25
- const API = process.env.SMARTLINKS_API || 'https://smartlinks.app'
26
- const KEY = process.env.SMARTLINKS_DEPLOY_KEY
27
- const APP_ID = process.env.SMARTLINKS_APP_ID
28
- const CHANNEL = flag('--channel', process.env.SMARTLINKS_CHANNEL || 'dev')
29
- const DIR = flag('--dir', 'dist')
30
- const WATCH = args.includes('--watch')
31
-
32
- // Mirror of the server's write allowlist — a bundle is static web assets only.
33
- const ALLOWED = new Set(['.js', '.mjs', '.css', '.json', '.map', '.wasm', '.html', '.txt',
34
- '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.woff', '.woff2', '.ttf', '.otf'])
35
- const extOf = (p) => { const i = p.lastIndexOf('.'); return i < 0 ? '' : p.slice(i).toLowerCase() }
36
-
37
- function fail(msg) { console.error(`❌ ${msg}`); process.exit(1) }
38
- if (!APP_ID) fail('SMARTLINKS_APP_ID is required')
39
- if (!KEY) fail('SMARTLINKS_DEPLOY_KEY is required')
40
-
41
- function collectFiles(dir) {
42
- const out = {}
43
- const walk = (d) => {
44
- for (const e of readdirSync(d, { withFileTypes: true })) {
45
- const full = join(d, e.name)
46
- if (e.isDirectory()) { walk(full); continue }
47
- if (!ALLOWED.has(extOf(e.name))) continue
48
- const rel = relative(dir, full).split(sep).join('/') // forward slashes for the bucket
49
- out[rel] = readFileSync(full)
50
- }
51
- }
52
- walk(dir)
53
- return out
54
- }
55
-
56
- // One request carries at most this much file content (decoded). The server caps a request at 8 MB of
57
- // content and ~10 MB of JSON; base64 adds a third, so 6 MB keeps every request comfortably under both.
58
- const BATCH_BYTES = 6 * 1024 * 1024
59
- const BATCH_FILES = 150
60
-
61
- async function post(path, body) {
62
- const res = await fetch(`${API}/api/v1/apps/${APP_ID}${path}`, {
63
- method: 'POST',
64
- headers: { 'content-type': 'application/json', 'x-smartlinks-deploy-key': KEY },
65
- body: JSON.stringify(body),
66
- }).catch((e) => ({ ok: false, status: 0, json: async () => ({ errors: [{ message: `request failed — ${e.message}` }] }) }))
67
- const b = await res.json().catch(() => ({}))
68
- return { res, b }
69
- }
70
-
71
- function reportFailure(res, b) {
72
- console.error(`❌ publish failed (${res.status}):`)
73
- for (const e of b.errors || []) console.error(` • ${e.path || ''}: ${e.message}`)
74
- if (!b.errors && (b.message || b.error)) console.error(` • ${b.message || b.error}`)
75
- }
76
-
77
- const toB64 = (entries) => Object.fromEntries(entries.map(([p, buf]) => [p, buf.toString('base64')]))
78
-
79
- async function publishOnce() {
80
- let stat
81
- try { stat = statSync(DIR) } catch { fail(`build dir not found: ${DIR} (build first)`) }
82
- if (!stat.isDirectory()) fail(`${DIR} is not a directory`)
83
-
84
- const files = collectFiles(DIR)
85
- if (!files['app.manifest.json']) fail(`${DIR}/app.manifest.json not found — is this a built app bundle?`)
86
-
87
- const t0 = Date.now()
88
- const entries = Object.entries(files)
89
- const totalBytes = entries.reduce((n, [, buf]) => n + buf.length, 0)
90
- const qs = `?channel=${encodeURIComponent(CHANNEL)}`
91
- let finalBody
92
-
93
- if (totalBytes <= BATCH_BYTES && entries.length <= BATCH_FILES) {
94
- finalBody = { files: toB64(entries) } // small bundle: one request, as before
95
- } else {
96
- // Large bundle: upload everything except the manifest in batches, then send the manifest last —
97
- // the server only registers the release once every referenced file is confirmed uploaded.
98
- const rest = entries.filter(([p]) => p !== 'app.manifest.json').sort((a, b) => b[1].length - a[1].length)
99
- const batches = []
100
- let cur = [], curBytes = 0
101
- for (const e of rest) {
102
- if (cur.length && (curBytes + e[1].length > BATCH_BYTES || cur.length >= BATCH_FILES)) { batches.push(cur); cur = []; curBytes = 0 }
103
- if (e[1].length > BATCH_BYTES) console.warn(` ⚠︎ ${e[0]} is ${(e[1].length / 1048576).toFixed(1)} MB — larger than one request allows; the server will likely reject it`)
104
- cur.push(e); curBytes += e[1].length
105
- }
106
- if (cur.length) batches.push(cur)
107
- console.log(`📦 ${APP_ID}: ${(totalBytes / 1048576).toFixed(1)} MB in ${entries.length} files — uploading in ${batches.length} batch(es)`)
108
- for (let i = 0; i < batches.length; i++) {
109
- const { res, b } = await post(`/publish/files${qs}`, { files: toB64(batches[i]) })
110
- if (!res.ok || !b.ok) {
111
- reportFailure(res, b)
112
- if (WATCH) return false
113
- process.exit(1)
114
- }
115
- console.log(` ↑ batch ${i + 1}/${batches.length} (${batches[i].length} files)`)
116
- }
117
- finalBody = { files: toB64([['app.manifest.json', files['app.manifest.json']]]), uploaded: rest.map(([p]) => p) }
118
- }
119
-
120
- const { res, b } = await post(`/publish${qs}`, finalBody)
121
- if (!res.ok || !b.ok) {
122
- reportFailure(res, b)
123
- if (WATCH) return false // keep watching; don't exit
124
- process.exit(1)
125
- }
126
- for (const w of b.warnings || []) console.warn(` ⚠︎ ${w.path}: ${w.message}`)
127
- console.log(`✅ ${APP_ID}@${b.version} → ${CHANNEL} (${entries.length} files, ${Date.now() - t0}ms) — functions: ${(b.functions || []).join(', ') || 'none'}`)
128
- console.log(` ${b.bundleBaseUrl}`)
129
- return true
130
- }
131
-
132
- if (!WATCH) {
133
- await publishOnce()
134
- } else {
135
- console.log(`👀 watching ${DIR} — publishing ${APP_ID} to ${CHANNEL} on change (Ctrl+C to stop)`)
136
- await publishOnce()
137
- let timer = null
138
- let publishing = false
139
- watch(DIR, { recursive: true }, () => {
140
- clearTimeout(timer)
141
- timer = setTimeout(async () => {
142
- if (publishing) return
143
- publishing = true
144
- try { await publishOnce() } finally { publishing = false }
145
- }, 300) // debounce a burst of file writes from one build
146
- })
147
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * publish — the FAST dev publish for a SmartLinks app. Builds nothing itself: it takes an already-
4
+ * built `dist/`, uploads it to the SmartLinks publish endpoint, which writes it straight to the CDN
5
+ * (dev = no-store, instantly fresh) and registers the release. One call = host + register, no Cloud
6
+ * Build, no Lovable. Works from anywhere: local, Claude, CI, or a build step.
7
+ *
8
+ * smartlinks-publish # build dist/ yourself first, then this
9
+ * smartlinks-publish --watch # re-publish on file change (save → live)
10
+ *
11
+ * Env:
12
+ * SMARTLINKS_APP_ID the platform app id (required)
13
+ * SMARTLINKS_DEPLOY_KEY channel-scoped deploy key (dev key is dev-only, safe to keep locally)
14
+ * SMARTLINKS_API API host (default https://smartlinks.app)
15
+ * SMARTLINKS_CHANNEL channel (default dev; only dev is supported by this fast path today)
16
+ * Flags: --dir <dist> --watch --channel <dev>
17
+ */
18
+
19
+ import { readFileSync, readdirSync, statSync, watch } from 'node:fs'
20
+ import { join, relative, sep } from 'node:path'
21
+
22
+ const args = process.argv.slice(2)
23
+ const flag = (name, def) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def }
24
+
25
+ const API = process.env.SMARTLINKS_API || 'https://smartlinks.app'
26
+ const KEY = process.env.SMARTLINKS_DEPLOY_KEY
27
+ const APP_ID = process.env.SMARTLINKS_APP_ID
28
+ const CHANNEL = flag('--channel', process.env.SMARTLINKS_CHANNEL || 'dev')
29
+ const DIR = flag('--dir', 'dist')
30
+ const WATCH = args.includes('--watch')
31
+
32
+ // Mirror of the server's write allowlist — a bundle is static web assets only. Source maps (.map) are
33
+ // never published: a release is promoted to beta and stable as-is, so a map would serve the app's
34
+ // original source publicly (and maps are most of a bundle's size). Debug in Forge's preview; a
35
+ // Release to dev is a readable, unminified build instead.
36
+ const ALLOWED = new Set(['.js', '.mjs', '.css', '.json', '.wasm', '.html', '.txt',
37
+ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.woff', '.woff2', '.ttf', '.otf'])
38
+ const extOf = (p) => { const i = p.lastIndexOf('.'); return i < 0 ? '' : p.slice(i).toLowerCase() }
39
+
40
+ function fail(msg) { console.error(`❌ ${msg}`); process.exit(1) }
41
+ if (!APP_ID) fail('SMARTLINKS_APP_ID is required')
42
+ if (!KEY) fail('SMARTLINKS_DEPLOY_KEY is required')
43
+
44
+ let skippedMaps = 0
45
+
46
+ function collectFiles(dir) {
47
+ const out = {}
48
+ const walk = (d) => {
49
+ for (const e of readdirSync(d, { withFileTypes: true })) {
50
+ const full = join(d, e.name)
51
+ if (e.isDirectory()) { walk(full); continue }
52
+ if (!ALLOWED.has(extOf(e.name))) { if (extOf(e.name) === '.map') skippedMaps++; continue }
53
+ const rel = relative(dir, full).split(sep).join('/') // forward slashes for the bucket
54
+ out[rel] = readFileSync(full)
55
+ }
56
+ }
57
+ walk(dir)
58
+ return out
59
+ }
60
+
61
+ // One request carries at most this much file content (decoded). The server caps a request at 8 MB of
62
+ // content and ~10 MB of JSON; base64 adds a third, so 6 MB keeps every request comfortably under both.
63
+ const BATCH_BYTES = 6 * 1024 * 1024
64
+ const BATCH_FILES = 150
65
+ // A file at least this big goes straight to storage (a session URL from /publish/uploads) instead of
66
+ // through an API request, so no single file is ever too large to publish (e.g. a readable dev build).
67
+ const DIRECT_BYTES = 4 * 1024 * 1024
68
+
69
+ // Big files → storage directly. Returns the paths uploaded, or null when the server has no direct
70
+ // uploads yet (an older API: the caller falls back to batches).
71
+ async function uploadDirect(entries, qs) {
72
+ const { res, b } = await post(`/publish/uploads${qs}`, { files: entries.map(([p, buf]) => ({ path: p, size: buf.length })) })
73
+ if (res.status === 404) return null
74
+ if (!res.ok || !b.ok) { reportFailure(res, b); return false }
75
+ const bytes = new Map(entries)
76
+ for (const u of b.uploads || []) {
77
+ const buf = bytes.get(u.path)
78
+ const put = await fetch(u.url, { method: 'PUT', body: buf }).catch((e) => ({ ok: false, status: 0, text: async () => e.message }))
79
+ if (!put.ok) {
80
+ console.error(`❌ upload of ${u.path} failed (${put.status}): ${(await put.text().catch(() => '')).slice(0, 200)}`)
81
+ return false
82
+ }
83
+ console.log(` ↑ ${u.path} (${(buf.length / 1048576).toFixed(1)} MB, direct)`)
84
+ }
85
+ return entries.map(([p]) => p)
86
+ }
87
+
88
+ async function post(path, body) {
89
+ const res = await fetch(`${API}/api/v1/apps/${APP_ID}${path}`, {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json', 'x-smartlinks-deploy-key': KEY },
92
+ body: JSON.stringify(body),
93
+ }).catch((e) => ({ ok: false, status: 0, json: async () => ({ errors: [{ message: `request failed — ${e.message}` }] }) }))
94
+ const b = await res.json().catch(() => ({}))
95
+ return { res, b }
96
+ }
97
+
98
+ function reportFailure(res, b) {
99
+ console.error(`❌ publish failed (${res.status}):`)
100
+ for (const e of b.errors || []) console.error(` • ${e.path || ''}: ${e.message}`)
101
+ if (!b.errors && (b.message || b.error)) console.error(` • ${b.message || b.error}`)
102
+ }
103
+
104
+ const toB64 = (entries) => Object.fromEntries(entries.map(([p, buf]) => [p, buf.toString('base64')]))
105
+
106
+ async function publishOnce() {
107
+ let stat
108
+ try { stat = statSync(DIR) } catch { fail(`build dir not found: ${DIR} (build first)`) }
109
+ if (!stat.isDirectory()) fail(`${DIR} is not a directory`)
110
+
111
+ skippedMaps = 0
112
+ const files = collectFiles(DIR)
113
+ if (!files['app.manifest.json']) fail(`${DIR}/app.manifest.json not found — is this a built app bundle?`)
114
+ if (skippedMaps) console.log(` (${skippedMaps} source map${skippedMaps === 1 ? '' : 's'} left out — maps are never published)`)
115
+
116
+ const t0 = Date.now()
117
+ const entries = Object.entries(files)
118
+ const totalBytes = entries.reduce((n, [, buf]) => n + buf.length, 0)
119
+ const qs = `?channel=${encodeURIComponent(CHANNEL)}`
120
+ let finalBody
121
+
122
+ if (totalBytes <= BATCH_BYTES && entries.length <= BATCH_FILES) {
123
+ finalBody = { files: toB64(entries) } // small bundle: one request, as before
124
+ } else {
125
+ // Large bundle: upload everything except the manifest in batches, then send the manifest last —
126
+ // the server only registers the release once every referenced file is confirmed uploaded.
127
+ const rest = entries.filter(([p]) => p !== 'app.manifest.json').sort((a, b) => b[1].length - a[1].length)
128
+ const big = rest.filter(([, buf]) => buf.length >= DIRECT_BYTES)
129
+ let direct = []
130
+ if (big.length) {
131
+ const done = await uploadDirect(big, qs)
132
+ if (done === false) { if (WATCH) return false; process.exit(1) }
133
+ if (done) direct = done
134
+ else console.warn(' ⚠︎ this SmartLinks API has no direct uploads yet — sending big files in batches')
135
+ }
136
+ const viaBatches = rest.filter(([p]) => !direct.includes(p))
137
+ const batches = []
138
+ let cur = [], curBytes = 0
139
+ for (const e of viaBatches) {
140
+ if (cur.length && (curBytes + e[1].length > BATCH_BYTES || cur.length >= BATCH_FILES)) { batches.push(cur); cur = []; curBytes = 0 }
141
+ if (e[1].length > BATCH_BYTES) console.warn(` ⚠︎ ${e[0]} is ${(e[1].length / 1048576).toFixed(1)} MB — larger than one request allows; the server will likely reject it`)
142
+ cur.push(e); curBytes += e[1].length
143
+ }
144
+ if (cur.length) batches.push(cur)
145
+ console.log(`📦 ${APP_ID}: ${(totalBytes / 1048576).toFixed(1)} MB in ${entries.length} files — ${direct.length ? `${direct.length} sent directly, ` : ''}${batches.length} batch(es)`)
146
+ for (let i = 0; i < batches.length; i++) {
147
+ const { res, b } = await post(`/publish/files${qs}`, { files: toB64(batches[i]) })
148
+ if (!res.ok || !b.ok) {
149
+ reportFailure(res, b)
150
+ if (WATCH) return false
151
+ process.exit(1)
152
+ }
153
+ console.log(` ↑ batch ${i + 1}/${batches.length} (${batches[i].length} files)`)
154
+ }
155
+ finalBody = { files: toB64([['app.manifest.json', files['app.manifest.json']]]), uploaded: rest.map(([p]) => p) }
156
+ }
157
+
158
+ const { res, b } = await post(`/publish${qs}`, finalBody)
159
+ if (!res.ok || !b.ok) {
160
+ reportFailure(res, b)
161
+ if (WATCH) return false // keep watching; don't exit
162
+ process.exit(1)
163
+ }
164
+ for (const w of b.warnings || []) console.warn(` ⚠︎ ${w.path}: ${w.message}`)
165
+ console.log(`✅ ${APP_ID}@${b.version} → ${CHANNEL} (${entries.length} files, ${Date.now() - t0}ms) — functions: ${(b.functions || []).join(', ') || 'none'}`)
166
+ console.log(` ${b.bundleBaseUrl}`)
167
+ return true
168
+ }
169
+
170
+ if (!WATCH) {
171
+ await publishOnce()
172
+ } else {
173
+ console.log(`👀 watching ${DIR} — publishing ${APP_ID} to ${CHANNEL} on change (Ctrl+C to stop)`)
174
+ await publishOnce()
175
+ let timer = null
176
+ let publishing = false
177
+ watch(DIR, { recursive: true }, () => {
178
+ clearTimeout(timer)
179
+ timer = setTimeout(async () => {
180
+ if (publishing) return
181
+ publishing = true
182
+ try { await publishOnce() } finally { publishing = false }
183
+ }, 300) // debounce a burst of file writes from one build
184
+ })
185
+ }