@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.
- package/dist/api/authKit.d.ts +28 -4
- package/dist/api/authKit.js +35 -1
- package/dist/docs/API_SUMMARY.md +73 -11
- package/dist/docs/auth-kit.md +46 -5
- package/dist/docs/deploying-apps.md +9 -0
- package/dist/docs/members.md +182 -0
- package/dist/docs/overview.md +476 -475
- package/dist/docs/server-functions.md +4 -1
- package/dist/openapi.yaml +201 -20
- package/dist/testing/index.js +1 -1
- package/dist/types/appManifest.d.ts +16 -4
- package/dist/types/authKit.d.ts +58 -7
- package/docs/API_SUMMARY.md +73 -11
- package/docs/auth-kit.md +46 -5
- package/docs/deploying-apps.md +9 -0
- package/docs/members.md +182 -0
- package/docs/overview.md +476 -475
- package/docs/server-functions.md +4 -1
- package/openapi.yaml +201 -20
- package/package.json +1 -1
- package/scripts/publish.mjs +185 -147
package/docs/server-functions.md
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
22898
|
-
enum:
|
|
22899
|
-
- email
|
|
22900
|
-
- sms
|
|
23015
|
+
$ref: "#/components/schemas/MfaFactor"
|
|
22901
23016
|
preferredFactor:
|
|
22902
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/scripts/publish.mjs
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
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
|
+
}
|