synqauth 0.0.0 → 0.3.0
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/LICENSE +21 -0
- package/README.md +35 -2
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +6 -0
- package/dist/cli-C7dDFb81.js +950 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2 -0
- package/package.json +37 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aerosol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# synqauth
|
|
2
2
|
|
|
3
|
-
Synq's
|
|
3
|
+
Synq's command line. `npx synqauth init` gives your Next.js app a working sign-in, with no account: try Synq on your laptop first, and claim it when you sign up.
|
|
4
4
|
|
|
5
|
-
https://synq.build
|
|
5
|
+
Synq is customer identity for businesses: an OpenID Connect issuer for each of your brands, with social, email, passkey and Solana wallet sign-in. The documentation is at [docs.synq.build](https://docs.synq.build).
|
|
6
|
+
|
|
7
|
+
This package is at 0.x: its commands can change in any release until Synq's first customer depends on it.
|
|
8
|
+
|
|
9
|
+
## `npx synqauth init`
|
|
10
|
+
|
|
11
|
+
Run it in a Next.js App Router project (an `app/` or `src/app/` folder):
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx synqauth init
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
It asks Synq for a development sign-in: an org of your own with one app, on `https://localhost:<your dev port>` only, named after your package (`@acme/web` is `acme-web`). Then it:
|
|
18
|
+
|
|
19
|
+
- writes the app's keys to `.env.local` (`SYNQ_ISSUER`, `SYNQ_CLIENT_ID`, `SYNQ_CLIENT_SECRET`, `SYNQ_APP_URL`, `SYNQ_SESSION_SECRET` and `SYNQ_CLAIM_TOKEN`), and makes sure git ignores it: it adds `.env*.local` to your app's `.gitignore` only where git doesn't ignore the file already (outside a git repo, where `.gitignore` doesn't say so)
|
|
20
|
+
- adds `@synqauth/nextjs` with your package manager
|
|
21
|
+
- adds `synq.config.ts`, `lib/synq.ts`, the route mount at `app/api/auth/[...synq]/route.ts`, and a page at `/synq` that signs you in and shows who
|
|
22
|
+
- makes your `dev` script serve https (`next dev --experimental-https`), which Synq's development redirect URIs need
|
|
23
|
+
|
|
24
|
+
It never overwrites a file you have, and stops before making anything when `.env.local` already has Synq's values (`--force` makes another).
|
|
25
|
+
|
|
26
|
+
Start your app and open `https://localhost:3000/synq` to sign in. Its first start may ask for your computer's password, once, to trust a local certificate: Next.js makes its https certificate with mkcert. Accept it, since sign-in needs the https address. Where the prompt can't be answered (a shell with no terminal, an agent, CI), Next.js serves `http://localhost:3000` instead, and the `/synq` page says to restart `dev` in a terminal and accept the prompt. To keep the sign-in, open the claim link it prints (it's also in `.env.local`) within 7 days and sign up: the org is yours, with no limits but your plan's. Until then it signs in up to 25 people, on localhost only, and Synq deletes it if nobody claims it.
|
|
27
|
+
|
|
28
|
+
`npm create synq` does the same.
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
|
|
32
|
+
- `--force`: make another development sign-in, even where `.env.local` has Synq's values
|
|
33
|
+
- `--no-install`: don't add `@synqauth/nextjs`
|
|
34
|
+
- `--stage preview|local`: for working on Synq itself; `local` reads `SYNQ_API_URL` and `SYNQ_WEBSITE_URL`
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { closeSync, constants, existsSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join, posix, relative } from "node:path";
|
|
6
|
+
//#region src/api.ts
|
|
7
|
+
/** Why Synq didn't make one, in words for the developer. */
|
|
8
|
+
var SynqApiError = class extends Error {
|
|
9
|
+
code;
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, code, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "SynqApiError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.status = status;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
async function createClaimableOrg(api, request, options) {
|
|
19
|
+
const send = options.fetch ?? fetch;
|
|
20
|
+
let res;
|
|
21
|
+
try {
|
|
22
|
+
res = await send(new URL("/v1/claimable-orgs", api), {
|
|
23
|
+
method: "POST",
|
|
24
|
+
signal: AbortSignal.timeout(15e3),
|
|
25
|
+
redirect: "error",
|
|
26
|
+
headers: {
|
|
27
|
+
"content-type": "application/json",
|
|
28
|
+
"user-agent": options.userAgent
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
name: request.name,
|
|
32
|
+
app_type: request.appType,
|
|
33
|
+
redirect_uris: request.redirectUris,
|
|
34
|
+
post_logout_redirect_uris: request.postLogoutRedirectUris
|
|
35
|
+
})
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
throw new SynqApiError(`Couldn't reach Synq at ${api}. Check your connection and try again.`, "network");
|
|
39
|
+
}
|
|
40
|
+
if (res.status === 201) {
|
|
41
|
+
const made = await res.json().catch(() => void 0);
|
|
42
|
+
if (!isClaimableOrg(made)) throw new SynqApiError("Synq returned an incomplete development sign-in. No credentials were written; try again later.", "invalid_response", 201);
|
|
43
|
+
return made;
|
|
44
|
+
}
|
|
45
|
+
const problem = await res.json().catch(() => ({})) ?? {};
|
|
46
|
+
const code = [
|
|
47
|
+
"rate_limited",
|
|
48
|
+
"org_creation_closed",
|
|
49
|
+
"validation_failed"
|
|
50
|
+
].includes(String(problem.code)) ? String(problem.code) : `http_${res.status}`;
|
|
51
|
+
if (res.status === 429) {
|
|
52
|
+
const wait = Number(res.headers.get("retry-after"));
|
|
53
|
+
throw new SynqApiError(`Synq makes a few development sign-ins a day from one address, and this one has had them.${Number.isFinite(wait) && wait > 0 ? ` Try again in ${wait >= 3600 ? `${Math.ceil(wait / 3600)} hours` : `${Math.ceil(wait / 60)} minutes`}` : " Try again later"}, or sign up at ${options.website} to make an app in the dashboard.`, code, 429);
|
|
54
|
+
}
|
|
55
|
+
if (code === "org_creation_closed") throw new SynqApiError(`Synq can't make a development sign-in here yet. Sign up at ${options.website} to make an app in the dashboard.`, code, res.status);
|
|
56
|
+
throw new SynqApiError(`Synq refused (${code}). Check the project name and localhost redirect settings, then try again.`, code, res.status);
|
|
57
|
+
}
|
|
58
|
+
/** Check the one-time credentials before writing anything into a project. */
|
|
59
|
+
function isClaimableOrg(value) {
|
|
60
|
+
const object = (v) => typeof v === "object" && v !== null;
|
|
61
|
+
const text = (v) => typeof v === "string" && v.length > 0 && !v.includes("$") && [...v].every((char) => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127);
|
|
62
|
+
const url = (v) => text(v) && URL.canParse(v) && (new URL(v).protocol === "https:" || new URL(v).protocol === "http:" && /^(localhost|127\.0\.0\.1|\[::1\])$/.test(new URL(v).hostname)) && !new URL(v).username && !new URL(v).password;
|
|
63
|
+
if (!object(value)) return false;
|
|
64
|
+
const { org, app, claim, limits } = value;
|
|
65
|
+
return object(org) && text(org.id) && text(org.slug) && text(org.name) && url(value.issuer) && object(app) && text(app.client_id) && text(app.client_secret) && Array.isArray(app.redirect_uris) && app.redirect_uris.every(url) && object(claim) && text(claim.token) && /^synq_claim_[A-Za-z0-9_-]{43}$/.test(claim.token) && (claim.url === null || url(claim.url)) && text(claim.expires_at) && Number.isFinite(Date.parse(claim.expires_at)) && object(limits) && typeof limits.users === "number" && Number.isSafeInteger(limits.users) && limits.users > 0;
|
|
66
|
+
}
|
|
67
|
+
Object.freeze({
|
|
68
|
+
mainnet: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
|
|
69
|
+
devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
|
|
70
|
+
testnet: "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY"
|
|
71
|
+
});
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region ../links/src/host-map.ts
|
|
74
|
+
const HOST_MAP = [
|
|
75
|
+
{
|
|
76
|
+
product: "aerosol",
|
|
77
|
+
surface: "website",
|
|
78
|
+
production: ["https://www.aerosol.build", "https://aerosol.build"],
|
|
79
|
+
staging: null,
|
|
80
|
+
preview: "https://preview.aerosol.build"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
product: "burn-and-claim",
|
|
84
|
+
surface: "admin",
|
|
85
|
+
production: ["https://admin.burnandclaim.com"],
|
|
86
|
+
staging: null,
|
|
87
|
+
preview: "https://preview-admin.burnandclaim.com"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
product: "burn-and-claim",
|
|
91
|
+
surface: "analytics",
|
|
92
|
+
production: ["https://analytics.burnandclaim.com"],
|
|
93
|
+
staging: null,
|
|
94
|
+
preview: "https://preview-analytics.burnandclaim.com"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
product: "burn-and-claim",
|
|
98
|
+
surface: "api",
|
|
99
|
+
production: ["https://api.burnandclaim.com"],
|
|
100
|
+
staging: null,
|
|
101
|
+
preview: "https://preview-api.burnandclaim.com"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
product: "burn-and-claim",
|
|
105
|
+
surface: "app",
|
|
106
|
+
production: ["https://app.burnandclaim.com"],
|
|
107
|
+
staging: "https://staging-app.burnandclaim.com",
|
|
108
|
+
preview: "https://preview-app.burnandclaim.com"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
product: "burn-and-claim",
|
|
112
|
+
surface: "dashboard",
|
|
113
|
+
production: ["https://dashboard.burnandclaim.com"],
|
|
114
|
+
staging: null,
|
|
115
|
+
preview: "https://preview-dashboard.burnandclaim.com"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
product: "burn-and-claim",
|
|
119
|
+
surface: "docs",
|
|
120
|
+
production: ["https://docs.burnandclaim.com"],
|
|
121
|
+
staging: null,
|
|
122
|
+
preview: "https://preview-docs.burnandclaim.com"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
product: "burn-and-claim",
|
|
126
|
+
surface: "domains",
|
|
127
|
+
production: ["https://domains.burnandclaim.com"],
|
|
128
|
+
staging: null,
|
|
129
|
+
preview: "https://preview-domains.burnandclaim.com"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
product: "burn-and-claim",
|
|
133
|
+
surface: "media",
|
|
134
|
+
production: ["https://media.burnandclaim.com"],
|
|
135
|
+
staging: null,
|
|
136
|
+
preview: "https://preview-media.burnandclaim.com"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
product: "burn-and-claim",
|
|
140
|
+
surface: "website",
|
|
141
|
+
production: [
|
|
142
|
+
"https://www.burnandclaim.com",
|
|
143
|
+
"https://burnandclaim.com",
|
|
144
|
+
"https://beta-website.burnandclaim.com"
|
|
145
|
+
],
|
|
146
|
+
staging: null,
|
|
147
|
+
preview: "https://preview.burnandclaim.com"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
product: "mollyverse",
|
|
151
|
+
surface: "website",
|
|
152
|
+
production: ["https://www.mollyverse.art", "https://mollyverse.art"],
|
|
153
|
+
staging: "https://staging.mollyverse.art",
|
|
154
|
+
preview: "https://preview.mollyverse.art"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
product: "rodai",
|
|
158
|
+
surface: "api",
|
|
159
|
+
production: ["https://api.rodai.meme"],
|
|
160
|
+
staging: null,
|
|
161
|
+
preview: "https://preview-api.rodai.meme"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
product: "rodai",
|
|
165
|
+
surface: "website",
|
|
166
|
+
production: ["https://www.rodai.meme", "https://rodai.meme"],
|
|
167
|
+
staging: null,
|
|
168
|
+
preview: "https://preview.rodai.meme"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
product: "seed-my-wallet",
|
|
172
|
+
surface: "api",
|
|
173
|
+
production: ["https://api.seedmywallet.com"],
|
|
174
|
+
staging: null,
|
|
175
|
+
preview: "https://preview-api.seedmywallet.com"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
product: "seed-my-wallet",
|
|
179
|
+
surface: "app",
|
|
180
|
+
production: ["https://app.seedmywallet.com"],
|
|
181
|
+
staging: null,
|
|
182
|
+
preview: "https://preview-app.seedmywallet.com"
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
product: "seed-my-wallet",
|
|
186
|
+
surface: "faucet",
|
|
187
|
+
production: ["https://faucet.seedmywallet.com"],
|
|
188
|
+
staging: null,
|
|
189
|
+
preview: "https://preview-faucet.seedmywallet.com"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
product: "seed-my-wallet",
|
|
193
|
+
surface: "website",
|
|
194
|
+
production: ["https://www.seedmywallet.com", "https://seedmywallet.com"],
|
|
195
|
+
staging: null,
|
|
196
|
+
preview: "https://preview.seedmywallet.com"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
product: "spotlight",
|
|
200
|
+
surface: "admin",
|
|
201
|
+
production: ["https://admin.spotlightads.app"],
|
|
202
|
+
staging: null,
|
|
203
|
+
preview: null
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
product: "spotlight",
|
|
207
|
+
surface: "api",
|
|
208
|
+
production: ["https://api.spotlightads.app"],
|
|
209
|
+
staging: null,
|
|
210
|
+
preview: "https://preview-api.spotlightads.app"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
product: "spotlight",
|
|
214
|
+
surface: "app",
|
|
215
|
+
production: ["https://app.spotlightads.app"],
|
|
216
|
+
staging: null,
|
|
217
|
+
preview: null
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
product: "spotlight",
|
|
221
|
+
surface: "docs",
|
|
222
|
+
production: ["https://docs.spotlightads.app"],
|
|
223
|
+
staging: null,
|
|
224
|
+
preview: null
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
product: "spotlight",
|
|
228
|
+
surface: "media",
|
|
229
|
+
production: ["https://media.spotlightads.app"],
|
|
230
|
+
staging: null,
|
|
231
|
+
preview: "https://preview-media.spotlightads.app"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
product: "spotlight",
|
|
235
|
+
surface: "website",
|
|
236
|
+
production: ["https://spotlightads.app", "https://www.spotlightads.app"],
|
|
237
|
+
staging: null,
|
|
238
|
+
preview: "https://preview.spotlightads.app"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
product: "synq",
|
|
242
|
+
surface: "accounts",
|
|
243
|
+
production: ["https://accounts.synq.build"],
|
|
244
|
+
staging: null,
|
|
245
|
+
preview: "https://preview-accounts.synq.build"
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
product: "synq",
|
|
249
|
+
surface: "admin",
|
|
250
|
+
production: ["https://admin.synq.build"],
|
|
251
|
+
staging: null,
|
|
252
|
+
preview: "https://preview-admin.synq.build"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
product: "synq",
|
|
256
|
+
surface: "api",
|
|
257
|
+
production: ["https://api.synq.build"],
|
|
258
|
+
staging: null,
|
|
259
|
+
preview: "https://preview-api.synq.build"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
product: "synq",
|
|
263
|
+
surface: "auth",
|
|
264
|
+
production: ["https://auth.synq.build"],
|
|
265
|
+
staging: null,
|
|
266
|
+
preview: "https://preview-auth.synq.build"
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
product: "synq",
|
|
270
|
+
surface: "dashboard",
|
|
271
|
+
production: ["https://dashboard.synq.build"],
|
|
272
|
+
staging: null,
|
|
273
|
+
preview: "https://preview-dashboard.synq.build"
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
product: "synq",
|
|
277
|
+
surface: "demo",
|
|
278
|
+
production: ["https://demo.synq.build"],
|
|
279
|
+
staging: null,
|
|
280
|
+
preview: "https://preview-demo.synq.build"
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
product: "synq",
|
|
284
|
+
surface: "docs",
|
|
285
|
+
production: ["https://docs.synq.build"],
|
|
286
|
+
staging: null,
|
|
287
|
+
preview: "https://preview-docs.synq.build"
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
product: "synq",
|
|
291
|
+
surface: "domains",
|
|
292
|
+
production: ["https://domains.synq.build"],
|
|
293
|
+
staging: null,
|
|
294
|
+
preview: "https://preview-domains.synq.build"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
product: "synq",
|
|
298
|
+
surface: "media",
|
|
299
|
+
production: ["https://media.synq.build"],
|
|
300
|
+
staging: null,
|
|
301
|
+
preview: "https://preview-media.synq.build"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
product: "synq",
|
|
305
|
+
surface: "website",
|
|
306
|
+
production: ["https://synq.build", "https://www.synq.build"],
|
|
307
|
+
staging: null,
|
|
308
|
+
preview: "https://preview.synq.build"
|
|
309
|
+
}
|
|
310
|
+
];
|
|
311
|
+
/** The site this copy is built into: the only line that differs between repos. */
|
|
312
|
+
const THIS_SITE = {
|
|
313
|
+
product: "synq",
|
|
314
|
+
surface: "website"
|
|
315
|
+
};
|
|
316
|
+
(() => {
|
|
317
|
+
const entry = HOST_MAP.find((candidate) => candidate.product === THIS_SITE.product && candidate.surface === THIS_SITE.surface);
|
|
318
|
+
if (!entry) throw new Error(`host-map.ts: no entry for ${THIS_SITE.product} ${THIS_SITE.surface}`);
|
|
319
|
+
return entry;
|
|
320
|
+
})();
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region ../links/src/environment.ts
|
|
323
|
+
const STAGES = [
|
|
324
|
+
"local",
|
|
325
|
+
"preview",
|
|
326
|
+
"staging",
|
|
327
|
+
"production"
|
|
328
|
+
];
|
|
329
|
+
const isStage = (value) => typeof value === "string" && STAGES.includes(value);
|
|
330
|
+
/**
|
|
331
|
+
* The variable a local stack sets for a surface's origin, since the host map
|
|
332
|
+
* has no local hosts: `<PRODUCT>_<SURFACE>_URL` in upper case, dashes as
|
|
333
|
+
* underscores (`SYNQ_API_URL`, `SEED_MY_WALLET_API_URL`).
|
|
334
|
+
*/
|
|
335
|
+
function localOriginVariable(product, surface) {
|
|
336
|
+
return `${product}_${surface}_URL`.toUpperCase().replace(/-/g, "_");
|
|
337
|
+
}
|
|
338
|
+
/** `value` as an http(s) origin, or undefined when it is anything more or less than one. */
|
|
339
|
+
function originOf(value) {
|
|
340
|
+
let url;
|
|
341
|
+
try {
|
|
342
|
+
url = new URL(value);
|
|
343
|
+
} catch {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
347
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) return void 0;
|
|
348
|
+
return url.origin;
|
|
349
|
+
}
|
|
350
|
+
function entryFor(product, surface) {
|
|
351
|
+
const entry = HOST_MAP.find((e) => e.product === product && e.surface === surface);
|
|
352
|
+
if (!entry) throw new Error(`host-map.ts has no ${product} ${surface}: give it a row in every repo's copy at once (AERO-1528)`);
|
|
353
|
+
return entry;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* HOST_MAP's origin for a surface in a stage: production[0], staging or
|
|
357
|
+
* preview. Local takes the override the AppHost sets,
|
|
358
|
+
* `<PRODUCT>_<SURFACE>_URL` (localOriginVariable). Throws when there is none:
|
|
359
|
+
* a stage never falls back to another stage's host.
|
|
360
|
+
*/
|
|
361
|
+
function originFor(product, surface, stage, env = {}) {
|
|
362
|
+
if (!isStage(stage)) throw new Error(`The stage must be one of ${STAGES.join(", ")}`);
|
|
363
|
+
const entry = entryFor(product, surface);
|
|
364
|
+
if (stage === "local") {
|
|
365
|
+
const variable = localOriginVariable(product, surface);
|
|
366
|
+
const value = env[variable]?.trim();
|
|
367
|
+
if (!value) throw new Error(`${variable} must be set locally: the host map has no local hosts`);
|
|
368
|
+
const origin = originOf(value);
|
|
369
|
+
if (!origin) throw new Error(`${variable} must be an http:// or https:// origin`);
|
|
370
|
+
return origin;
|
|
371
|
+
}
|
|
372
|
+
const origin = stage === "production" ? entry.production[0] : entry[stage];
|
|
373
|
+
if (!origin) throw new Error(`host-map.ts gives ${product} ${surface} no ${stage} host`);
|
|
374
|
+
return origin;
|
|
375
|
+
}
|
|
376
|
+
/** The API a stage reads: its own, and production's for staging (AERO-1526). */
|
|
377
|
+
function apiOriginFor(product, stage, env = {}) {
|
|
378
|
+
return originFor(product, "api", stage === "staging" ? "production" : stage, env);
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/env.ts
|
|
382
|
+
/** The names `synqauth init` writes. One already set means the project has Synq. */
|
|
383
|
+
const SYNQ_VARIABLES = [
|
|
384
|
+
"SYNQ_ISSUER",
|
|
385
|
+
"SYNQ_CLIENT_ID",
|
|
386
|
+
"SYNQ_CLIENT_SECRET",
|
|
387
|
+
"SYNQ_APP_URL",
|
|
388
|
+
"SYNQ_SESSION_SECRET",
|
|
389
|
+
"SYNQ_CLAIM_TOKEN"
|
|
390
|
+
];
|
|
391
|
+
/** The names set in an env file's text: `NAME=…`, `export NAME=…`, never a comment. */
|
|
392
|
+
function namesIn(text) {
|
|
393
|
+
const names = /* @__PURE__ */ new Set();
|
|
394
|
+
for (const line of text.split(/\r?\n/)) {
|
|
395
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
396
|
+
if (match) names.add(match[1]);
|
|
397
|
+
}
|
|
398
|
+
return names;
|
|
399
|
+
}
|
|
400
|
+
/** The variables of `SYNQ_VARIABLES` the text sets already. */
|
|
401
|
+
function synqNamesIn(text) {
|
|
402
|
+
const names = namesIn(text);
|
|
403
|
+
return SYNQ_VARIABLES.filter((name) => names.has(name));
|
|
404
|
+
}
|
|
405
|
+
/** A value as a dotenv line takes it: quoted when it has anything but plain characters. */
|
|
406
|
+
function quoted(value) {
|
|
407
|
+
return /^[A-Za-z0-9_./:@+=-]*$/.test(value) ? value : JSON.stringify(value);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* `existing` with Synq's block appended: the comment, then each value. The
|
|
411
|
+
* existing text is kept byte for byte, with a newline added only if it
|
|
412
|
+
* lacks a last one.
|
|
413
|
+
*/
|
|
414
|
+
function withSynqBlock(existing, values, comment) {
|
|
415
|
+
const head = existing === "" || existing.endsWith("\n") ? existing : `${existing}\n`;
|
|
416
|
+
return `${head}${head === "" ? "" : "\n"}${[...comment.map((line) => `# ${line}`), ...SYNQ_VARIABLES.map((name) => `${name}=${quoted(values[name])}`)].join("\n")}\n`;
|
|
417
|
+
}
|
|
418
|
+
/** Whether a `.gitignore`'s text keeps `.env.local` out of git. */
|
|
419
|
+
function ignoresEnvLocal(gitignore) {
|
|
420
|
+
let ignored = false;
|
|
421
|
+
for (const line of gitignore.split(/\r?\n/).map((line) => line.trim())) {
|
|
422
|
+
if (line.startsWith("!")) ignored = false;
|
|
423
|
+
if ([
|
|
424
|
+
".env.local",
|
|
425
|
+
".env*.local",
|
|
426
|
+
".env*",
|
|
427
|
+
".env.*",
|
|
428
|
+
"*.local",
|
|
429
|
+
"/.env.local",
|
|
430
|
+
"/.env*.local"
|
|
431
|
+
].includes(line)) ignored = true;
|
|
432
|
+
}
|
|
433
|
+
return ignored;
|
|
434
|
+
}
|
|
435
|
+
/** `.gitignore`'s text with `.env*.local` added, when it doesn't ignore `.env.local` yet. */
|
|
436
|
+
function withEnvLocalIgnored(gitignore) {
|
|
437
|
+
return ignoresEnvLocal(gitignore) ? void 0 : withEnvLocalLine(gitignore);
|
|
438
|
+
}
|
|
439
|
+
/** `.gitignore`'s text with `.env*.local` added under a comment, keeping every byte it had. */
|
|
440
|
+
function withEnvLocalLine(gitignore) {
|
|
441
|
+
const head = gitignore === "" || gitignore.endsWith("\n") ? gitignore : `${gitignore}\n`;
|
|
442
|
+
return `${head}${head === "" ? "" : "\n"}# Local secrets, such as Synq's (npx synqauth init)\n.env*.local\n`;
|
|
443
|
+
}
|
|
444
|
+
//#endregion
|
|
445
|
+
//#region src/nextjs.ts
|
|
446
|
+
/**
|
|
447
|
+
* The files for a project whose App Router folder is `appDir` (`app` or
|
|
448
|
+
* `src/app`), pointing at `docs`, Synq's documentation. The /synq page names
|
|
449
|
+
* `dev`'s address and command for when it's served over http.
|
|
450
|
+
*/
|
|
451
|
+
function nextjsFiles(appDir, docs, dev) {
|
|
452
|
+
const base = appDir === "src/app" ? "src" : "";
|
|
453
|
+
const lib = posix.join(base, "lib", "synq.ts");
|
|
454
|
+
const route = posix.join(appDir, "api", "auth", "[...synq]", "route.ts");
|
|
455
|
+
const page = posix.join(appDir, "synq", "page.tsx");
|
|
456
|
+
const importOf = (from, to) => {
|
|
457
|
+
const relative = posix.relative(posix.dirname(from), to).replace(/\.tsx?$/, "");
|
|
458
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
459
|
+
};
|
|
460
|
+
return [
|
|
461
|
+
{
|
|
462
|
+
path: "synq.config.ts",
|
|
463
|
+
why: "the configuration the server and a proxy share",
|
|
464
|
+
contents: [
|
|
465
|
+
"import { defineSynqConfig } from '@synqauth/nextjs/config'",
|
|
466
|
+
"",
|
|
467
|
+
"// Synq's settings: it reads SYNQ_ISSUER, SYNQ_CLIENT_ID, SYNQ_CLIENT_SECRET,",
|
|
468
|
+
`// SYNQ_APP_URL and SYNQ_SESSION_SECRET from .env.local. ${docs}`,
|
|
469
|
+
"export const synqConfig = defineSynqConfig({})",
|
|
470
|
+
""
|
|
471
|
+
].join("\n")
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
path: lib,
|
|
475
|
+
why: "Synq on your server: `await synq.me()` is who is signed in",
|
|
476
|
+
contents: [
|
|
477
|
+
"import { synqAuth } from '@synqauth/nextjs'",
|
|
478
|
+
"",
|
|
479
|
+
`import { synqConfig } from '${importOf(lib, "synq.config.ts")}'`,
|
|
480
|
+
"",
|
|
481
|
+
"export const synq = synqAuth(synqConfig)",
|
|
482
|
+
""
|
|
483
|
+
].join("\n")
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
path: route,
|
|
487
|
+
why: "the route mount: sign-in, the callback and sign-out under /api/auth",
|
|
488
|
+
contents: [
|
|
489
|
+
`import { synq } from '${importOf(route, lib)}'`,
|
|
490
|
+
"",
|
|
491
|
+
"export const { GET, POST, PATCH, DELETE } = synq.handlers",
|
|
492
|
+
""
|
|
493
|
+
].join("\n")
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
path: page,
|
|
497
|
+
why: "a page at /synq that signs you in and shows who: delete it once your own pages do",
|
|
498
|
+
contents: [
|
|
499
|
+
"import { headers } from 'next/headers'",
|
|
500
|
+
"",
|
|
501
|
+
`import { synq } from '${importOf(page, lib)}'`,
|
|
502
|
+
"",
|
|
503
|
+
"// Made by `npx synqauth init` to show Synq signing someone in to this app.",
|
|
504
|
+
`// Delete it once your own pages sign people in. ${docs}`,
|
|
505
|
+
"const style = { fontFamily: 'system-ui, sans-serif', maxWidth: 32 * 16, margin: '4rem auto', lineHeight: 1.5 }",
|
|
506
|
+
"",
|
|
507
|
+
"export default async function SynqPage() {",
|
|
508
|
+
" // `next dev --experimental-https` serves http when it couldn't trust its",
|
|
509
|
+
" // local certificate. Synq sends people back to the https address only.",
|
|
510
|
+
" if ((await headers()).get('x-forwarded-proto') === 'http') {",
|
|
511
|
+
" return (",
|
|
512
|
+
" <main style={style}>",
|
|
513
|
+
" <h1>Sign-in needs https</h1>",
|
|
514
|
+
" <p>",
|
|
515
|
+
" {\"This page is on http: the dev server couldn't trust its local certificate, so sign-in can't finish.\"}",
|
|
516
|
+
" </p>",
|
|
517
|
+
" <p>",
|
|
518
|
+
` Stop the dev server, start it again in a terminal with <code>${dev.command}</code>, and accept`,
|
|
519
|
+
" the prompt for your password. It asks once, to trust a local certificate. Then open{' '}",
|
|
520
|
+
` <a href="${dev.appUrl}/synq">${dev.appUrl}/synq</a>.`,
|
|
521
|
+
" </p>",
|
|
522
|
+
" </main>",
|
|
523
|
+
" )",
|
|
524
|
+
" }",
|
|
525
|
+
" const me = await synq.me()",
|
|
526
|
+
" return (",
|
|
527
|
+
" <main style={style}>",
|
|
528
|
+
" <h1>{me ? \"It works: you're signed in\" : 'Sign in with Synq'}</h1>",
|
|
529
|
+
" {me ? (",
|
|
530
|
+
" <>",
|
|
531
|
+
" <p>Signed in as {me.email ?? me.name ?? me.sub}.</p>",
|
|
532
|
+
" <form method=\"post\" action={synq.signOutPath}>",
|
|
533
|
+
" <button type=\"submit\">Sign out</button>",
|
|
534
|
+
" </form>",
|
|
535
|
+
" </>",
|
|
536
|
+
" ) : (",
|
|
537
|
+
" <p>",
|
|
538
|
+
" <a href={synq.signInPath({ returnTo: '/synq' })}>Sign in</a>",
|
|
539
|
+
" </p>",
|
|
540
|
+
" )}",
|
|
541
|
+
" </main>",
|
|
542
|
+
" )",
|
|
543
|
+
"}",
|
|
544
|
+
""
|
|
545
|
+
].join("\n")
|
|
546
|
+
}
|
|
547
|
+
];
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* A dev script that serves https, which Synq's development redirect URIs
|
|
551
|
+
* need (`https://localhost:3000/...`): `next dev` gains
|
|
552
|
+
* `--experimental-https`. Undefined when it has it already, or isn't
|
|
553
|
+
* `next dev`.
|
|
554
|
+
*/
|
|
555
|
+
function httpsDevScript(script) {
|
|
556
|
+
if (!script || !/\bnext\s+dev\b/.test(script) || /--experimental-https\b/.test(script)) return;
|
|
557
|
+
return script.replace(/\bnext\s+dev\b/, "next dev --experimental-https");
|
|
558
|
+
}
|
|
559
|
+
//#endregion
|
|
560
|
+
//#region src/project.ts
|
|
561
|
+
const DEFAULT_PORTS = {
|
|
562
|
+
nextjs: 3e3,
|
|
563
|
+
vite: 5173,
|
|
564
|
+
unknown: 3e3
|
|
565
|
+
};
|
|
566
|
+
/** Reads the project in `dir`. */
|
|
567
|
+
function readProject(dir) {
|
|
568
|
+
const manifest = readManifest(dir);
|
|
569
|
+
const framework = frameworkOf(manifest);
|
|
570
|
+
const appDir = framework === "nextjs" ? ["app", "src/app"].find((candidate) => existsSync(join(dir, candidate))) : void 0;
|
|
571
|
+
return {
|
|
572
|
+
dir,
|
|
573
|
+
name: projectName(manifest?.name, basename(dir)),
|
|
574
|
+
framework,
|
|
575
|
+
appDir,
|
|
576
|
+
packageManager: packageManagerOf(dir),
|
|
577
|
+
port: portOf(manifest?.scripts?.dev, framework),
|
|
578
|
+
manifest
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function readManifest(dir) {
|
|
582
|
+
try {
|
|
583
|
+
return JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
584
|
+
} catch {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function frameworkOf(manifest) {
|
|
589
|
+
const deps = {
|
|
590
|
+
...manifest?.dependencies,
|
|
591
|
+
...manifest?.devDependencies
|
|
592
|
+
};
|
|
593
|
+
if (deps.next) return "nextjs";
|
|
594
|
+
if (deps.vite) return "vite";
|
|
595
|
+
return "unknown";
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* A name Synq takes for an org, at most 100 characters: the package's, with
|
|
599
|
+
* its scope as a prefix (`@rodai/website` is `rodai-website`, so the claim
|
|
600
|
+
* page and the dashboard name the project someone knows), unless the name
|
|
601
|
+
* starts with it already (`@acme/acme-web` is `acme-web`); else the folder's.
|
|
602
|
+
*/
|
|
603
|
+
function projectName(packageName, folder) {
|
|
604
|
+
const scoped = /^@([^/]+)\/(.*)$/s.exec(packageName?.trim() ?? "");
|
|
605
|
+
const [scope, rest] = scoped ? [scoped[1].trim(), scoped[2].trim()] : ["", packageName?.trim()];
|
|
606
|
+
return [...(scope && rest && rest !== scope && !rest.startsWith(`${scope}-`) ? `${scope}-${rest}` : rest) || folder].filter((char) => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127).join("").trim().slice(0, 100) || "my-app";
|
|
607
|
+
}
|
|
608
|
+
const MANAGERS = [
|
|
609
|
+
"pnpm",
|
|
610
|
+
"yarn",
|
|
611
|
+
"bun",
|
|
612
|
+
"npm"
|
|
613
|
+
];
|
|
614
|
+
/**
|
|
615
|
+
* The package manager the project uses: from `dir` up to its git root, the
|
|
616
|
+
* nearest folder that says, by its package.json's `packageManager`, a
|
|
617
|
+
* lockfile or a pnpm workspace. An app inside a workspace (`apps/web`) has
|
|
618
|
+
* no lockfile of its own: its workspace root's says, and `npm install` there
|
|
619
|
+
* fails on `workspace:` dependencies. npm where nothing says.
|
|
620
|
+
*/
|
|
621
|
+
function packageManagerOf(dir) {
|
|
622
|
+
for (let at = dir;; at = dirname(at)) {
|
|
623
|
+
const named = /^(\w+)@/.exec(readManifest(at)?.packageManager ?? "")?.[1];
|
|
624
|
+
const declared = MANAGERS.find((manager) => manager === named);
|
|
625
|
+
if (declared) return declared;
|
|
626
|
+
if (existsSync(join(at, "pnpm-lock.yaml")) || existsSync(join(at, "pnpm-workspace.yaml"))) return "pnpm";
|
|
627
|
+
if (existsSync(join(at, "yarn.lock"))) return "yarn";
|
|
628
|
+
if (existsSync(join(at, "bun.lock")) || existsSync(join(at, "bun.lockb"))) return "bun";
|
|
629
|
+
if (existsSync(join(at, "package-lock.json"))) return "npm";
|
|
630
|
+
if (existsSync(join(at, ".git")) || dirname(at) === at) return "npm";
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** The port a dev script names (`--port 4000`, `-p 4000`), else the framework's. */
|
|
634
|
+
function portOf(devScript, framework) {
|
|
635
|
+
const named = devScript && /(?:--port[ =]|-p\s+)(\d{1,5})\b/.exec(devScript);
|
|
636
|
+
const port = named ? Number(named[1]) : DEFAULT_PORTS[framework];
|
|
637
|
+
return port > 0 && port < 65536 ? port : DEFAULT_PORTS[framework];
|
|
638
|
+
}
|
|
639
|
+
/** The command that adds `packages` with this package manager. */
|
|
640
|
+
function addCommand(manager, packages) {
|
|
641
|
+
switch (manager) {
|
|
642
|
+
case "pnpm": return [
|
|
643
|
+
"pnpm",
|
|
644
|
+
"add",
|
|
645
|
+
...packages
|
|
646
|
+
];
|
|
647
|
+
case "yarn": return [
|
|
648
|
+
"yarn",
|
|
649
|
+
"add",
|
|
650
|
+
...packages
|
|
651
|
+
];
|
|
652
|
+
case "bun": return [
|
|
653
|
+
"bun",
|
|
654
|
+
"add",
|
|
655
|
+
...packages
|
|
656
|
+
];
|
|
657
|
+
case "npm": return [
|
|
658
|
+
"npm",
|
|
659
|
+
"install",
|
|
660
|
+
...packages
|
|
661
|
+
];
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** The command that runs a package.json script. */
|
|
665
|
+
function runCommand(manager, script) {
|
|
666
|
+
return manager === "npm" ? `npm run ${script}` : `${manager} ${script}`;
|
|
667
|
+
}
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/init.ts
|
|
670
|
+
/** The CLI's own name, as npm and `npx` know it: one constant, so a rename is one line. */
|
|
671
|
+
const CLI_NAME = "synqauth";
|
|
672
|
+
/** The packages a Next.js app needs. */
|
|
673
|
+
const NEXTJS_PACKAGES = ["@synqauth/nextjs"];
|
|
674
|
+
/** Why init stopped before making anything, in words for the developer. */
|
|
675
|
+
var InitError = class extends Error {
|
|
676
|
+
code;
|
|
677
|
+
constructor(message, code) {
|
|
678
|
+
super(message);
|
|
679
|
+
this.name = "InitError";
|
|
680
|
+
this.code = code;
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
async function init(options) {
|
|
684
|
+
for (const path of [
|
|
685
|
+
"package.json",
|
|
686
|
+
".env.local",
|
|
687
|
+
".gitignore"
|
|
688
|
+
]) checkFile(options.dir, path);
|
|
689
|
+
const project = readProject(options.dir);
|
|
690
|
+
const docs = originFor("synq", "docs", "production");
|
|
691
|
+
if (!project.manifest) throw new InitError(`There's no package.json in ${options.dir}. Run it in your app's folder.`, "no_project");
|
|
692
|
+
if (project.framework !== "nextjs") throw new InitError(`${CLI_NAME} init sets up Next.js apps. For ${project.framework === "vite" ? "a Vite app" : "this project"}, follow ${docs}/getting-started.`, "not_nextjs");
|
|
693
|
+
if (!project.appDir) throw new InitError(`${CLI_NAME} init sets up the App Router (an app/ or src/app/ folder), which this project doesn't have. Follow ${docs}/getting-started/nextjs.`, "no_app_router");
|
|
694
|
+
const appUrl = `https://localhost:${project.port}`;
|
|
695
|
+
const devCommand = runCommand(project.packageManager, "dev");
|
|
696
|
+
const planned = nextjsFiles(project.appDir, docs, {
|
|
697
|
+
appUrl,
|
|
698
|
+
command: devCommand
|
|
699
|
+
});
|
|
700
|
+
for (const path of [
|
|
701
|
+
".env.local",
|
|
702
|
+
".gitignore",
|
|
703
|
+
"package.json",
|
|
704
|
+
...planned.map((file) => file.path)
|
|
705
|
+
]) checkFile(project.dir, path);
|
|
706
|
+
const dev = project.manifest.scripts?.dev;
|
|
707
|
+
if (dev && !/\bnext\s+dev\b/.test(dev)) throw new InitError("Set scripts.dev to next dev before running init, so its HTTPS address matches the app.", "dev_script");
|
|
708
|
+
if (spawnSync("git", [
|
|
709
|
+
"ls-files",
|
|
710
|
+
"--error-unmatch",
|
|
711
|
+
"--",
|
|
712
|
+
".env.local"
|
|
713
|
+
], {
|
|
714
|
+
cwd: project.dir,
|
|
715
|
+
stdio: "ignore"
|
|
716
|
+
}).status === 0) throw new InitError("Stop tracking .env.local in git before running init; ignore rules cannot protect a tracked file.", "unsafe_file");
|
|
717
|
+
const envPath = join(project.dir, ".env.local");
|
|
718
|
+
const envText = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
719
|
+
const present = synqNamesIn(envText);
|
|
720
|
+
if (present.length > 0 && !options.force) throw new InitError(`This project has Synq already: .env.local sets ${present.join(", ")}. Run ${CLI_NAME} init --force to make another development sign-in.`, "has_synq");
|
|
721
|
+
const made = await createClaimableOrg(apiOriginFor("synq", options.stage, options.env), {
|
|
722
|
+
name: project.name,
|
|
723
|
+
appType: "web",
|
|
724
|
+
redirectUris: [`${appUrl}/api/auth/callback`],
|
|
725
|
+
postLogoutRedirectUris: [appUrl]
|
|
726
|
+
}, {
|
|
727
|
+
fetch: options.fetch,
|
|
728
|
+
userAgent: `${CLI_NAME}/${options.version}`,
|
|
729
|
+
website: originFor("synq", "website", options.stage, options.env)
|
|
730
|
+
});
|
|
731
|
+
for (const path of [
|
|
732
|
+
".env.local",
|
|
733
|
+
".gitignore",
|
|
734
|
+
"package.json",
|
|
735
|
+
...planned.map((file) => file.path)
|
|
736
|
+
]) checkFile(project.dir, path);
|
|
737
|
+
if ((existsSync(envPath) ? readFileSync(envPath, "utf8") : "") !== envText) throw new InitError(".env.local changed during setup. Run init again after the other edit finishes.", "unsafe_file");
|
|
738
|
+
const date = new Date(made.claim.expires_at).toISOString().slice(0, 10);
|
|
739
|
+
const envBlock = withSynqBlock(envText, {
|
|
740
|
+
SYNQ_ISSUER: made.issuer,
|
|
741
|
+
SYNQ_CLIENT_ID: made.app.client_id,
|
|
742
|
+
SYNQ_CLIENT_SECRET: made.app.client_secret ?? "",
|
|
743
|
+
SYNQ_APP_URL: appUrl,
|
|
744
|
+
SYNQ_SESSION_SECRET: randomBytes(32).toString("base64"),
|
|
745
|
+
SYNQ_CLAIM_TOKEN: made.claim.token
|
|
746
|
+
}, [`Synq: a development sign-in from npx ${CLI_NAME} init, ${(options.now?.() ?? /* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.`, `Claim it by ${date} or Synq deletes it: ${made.claim.url ?? "sign up, then claim it with SYNQ_CLAIM_TOKEN"}`]);
|
|
747
|
+
const fd = openSync(envPath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 384);
|
|
748
|
+
try {
|
|
749
|
+
fchmodSync(fd, 384);
|
|
750
|
+
writeFileSync(fd, envBlock.slice(envText.length));
|
|
751
|
+
} finally {
|
|
752
|
+
closeSync(fd);
|
|
753
|
+
}
|
|
754
|
+
const written = [".env.local"];
|
|
755
|
+
const run = options.run ?? runIn;
|
|
756
|
+
const gitSays = await run([
|
|
757
|
+
"git",
|
|
758
|
+
"check-ignore",
|
|
759
|
+
"-q",
|
|
760
|
+
".env.local"
|
|
761
|
+
], project.dir, { quiet: true });
|
|
762
|
+
const gitignorePath = join(project.dir, ".gitignore");
|
|
763
|
+
const gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
|
|
764
|
+
const ignored = gitSays === 0 ? void 0 : gitSays === 1 ? withEnvLocalLine(gitignore) : withEnvLocalIgnored(gitignore);
|
|
765
|
+
if (ignored !== void 0) {
|
|
766
|
+
writeFileSync(gitignorePath, ignored);
|
|
767
|
+
written.push(".gitignore");
|
|
768
|
+
}
|
|
769
|
+
const kept = [];
|
|
770
|
+
for (const file of planned) {
|
|
771
|
+
const path = join(project.dir, file.path);
|
|
772
|
+
if (existsSync(path)) {
|
|
773
|
+
kept.push(file.path);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
777
|
+
writeFileSync(path, file.contents, { flag: "wx" });
|
|
778
|
+
written.push(file.path);
|
|
779
|
+
}
|
|
780
|
+
let devScript;
|
|
781
|
+
const https = dev ? httpsDevScript(dev) : "next dev --experimental-https";
|
|
782
|
+
if (https) {
|
|
783
|
+
const manifestPath = join(project.dir, "package.json");
|
|
784
|
+
const text = readFileSync(manifestPath, "utf8");
|
|
785
|
+
const indent = /^(\s+)"/m.exec(text)?.[1] ?? " ";
|
|
786
|
+
const manifest = JSON.parse(text);
|
|
787
|
+
manifest.scripts ??= {};
|
|
788
|
+
manifest.scripts.dev = https;
|
|
789
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, indent)}\n`);
|
|
790
|
+
devScript = {
|
|
791
|
+
from: dev ?? "(none)",
|
|
792
|
+
to: https
|
|
793
|
+
};
|
|
794
|
+
written.push("package.json");
|
|
795
|
+
}
|
|
796
|
+
let installed;
|
|
797
|
+
const deps = {
|
|
798
|
+
...project.manifest.dependencies,
|
|
799
|
+
...project.manifest.devDependencies
|
|
800
|
+
};
|
|
801
|
+
const missing = NEXTJS_PACKAGES.filter((name) => !deps[name]);
|
|
802
|
+
if (options.install && missing.length > 0) installed = await run(addCommand(project.packageManager, missing), project.dir) === 0;
|
|
803
|
+
return {
|
|
804
|
+
project,
|
|
805
|
+
made,
|
|
806
|
+
written,
|
|
807
|
+
kept,
|
|
808
|
+
devScript,
|
|
809
|
+
installed,
|
|
810
|
+
devCommand,
|
|
811
|
+
openUrl: `${appUrl}/synq`
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
/** Refuse symlinks, including parent folders, before making a remote org. */
|
|
815
|
+
function checkFile(root, path) {
|
|
816
|
+
let cursor = join(root, path);
|
|
817
|
+
while (relative(root, cursor) !== "") {
|
|
818
|
+
let stat;
|
|
819
|
+
try {
|
|
820
|
+
stat = lstatSync(cursor);
|
|
821
|
+
} catch (error) {
|
|
822
|
+
if (!["ENOENT", "ENOTDIR"].includes(error.code ?? "")) throw error;
|
|
823
|
+
}
|
|
824
|
+
if (stat?.isSymbolicLink() || cursor === join(root, path) && stat && !stat.isFile()) throw new InitError(`Cannot set up ${path}: use ordinary files and folders, without symlinks.`, "unsafe_file");
|
|
825
|
+
cursor = dirname(cursor);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Runs a command with the terminal's own input and output (none when quiet),
|
|
830
|
+
* answering its exit code: 127, as a shell does, when it can't start.
|
|
831
|
+
*/
|
|
832
|
+
function runIn(command, cwd, { quiet = false } = {}) {
|
|
833
|
+
return new Promise((resolve) => {
|
|
834
|
+
const child = spawn(command[0], command.slice(1), {
|
|
835
|
+
cwd,
|
|
836
|
+
stdio: quiet ? "ignore" : "inherit",
|
|
837
|
+
shell: process.platform === "win32"
|
|
838
|
+
});
|
|
839
|
+
child.on("error", () => resolve(127));
|
|
840
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region src/cli.ts
|
|
845
|
+
const VERSION = "0.3.0";
|
|
846
|
+
const HELP = `Usage: npx ${CLI_NAME} init [options]
|
|
847
|
+
|
|
848
|
+
Gives your Next.js app a working Synq sign-in, with no account: a development
|
|
849
|
+
sign-in you claim later by signing up.
|
|
850
|
+
|
|
851
|
+
Options:
|
|
852
|
+
--force Make another one even where .env.local has Synq's values
|
|
853
|
+
--no-install Don't add @synqauth/nextjs with your package manager
|
|
854
|
+
--stage <s> Which Synq: production (the default), preview, staging or local
|
|
855
|
+
-h, --help Show this
|
|
856
|
+
-v, --version Show the version`;
|
|
857
|
+
const console_ = {
|
|
858
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
859
|
+
err: (line) => process.stderr.write(`${line}\n`)
|
|
860
|
+
};
|
|
861
|
+
async function main(argv, context = {}) {
|
|
862
|
+
const output = context.output ?? console_;
|
|
863
|
+
let parsed;
|
|
864
|
+
try {
|
|
865
|
+
parsed = parse(argv);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
output.err(error.message);
|
|
868
|
+
output.err(HELP);
|
|
869
|
+
return 2;
|
|
870
|
+
}
|
|
871
|
+
if (parsed.values.version) {
|
|
872
|
+
output.out(VERSION);
|
|
873
|
+
return 0;
|
|
874
|
+
}
|
|
875
|
+
const [command] = parsed.positionals;
|
|
876
|
+
if (parsed.values.help || command === void 0) {
|
|
877
|
+
output.out(HELP);
|
|
878
|
+
return command === void 0 && !parsed.values.help ? 2 : 0;
|
|
879
|
+
}
|
|
880
|
+
if (command !== "init" || parsed.positionals.length > 1) {
|
|
881
|
+
output.err(`${CLI_NAME} has no command "${command}".`);
|
|
882
|
+
output.err(HELP);
|
|
883
|
+
return 2;
|
|
884
|
+
}
|
|
885
|
+
const stage = parsed.values.stage;
|
|
886
|
+
if (!STAGES.includes(stage)) {
|
|
887
|
+
output.err(`--stage must be one of ${STAGES.join(", ")}.`);
|
|
888
|
+
return 2;
|
|
889
|
+
}
|
|
890
|
+
try {
|
|
891
|
+
const result = await init({
|
|
892
|
+
dir: context.cwd ?? process.cwd(),
|
|
893
|
+
stage,
|
|
894
|
+
env: context.env ?? process.env,
|
|
895
|
+
force: parsed.values.force === true,
|
|
896
|
+
install: parsed.values["no-install"] !== true,
|
|
897
|
+
version: VERSION,
|
|
898
|
+
fetch: context.fetch,
|
|
899
|
+
run: context.run
|
|
900
|
+
});
|
|
901
|
+
for (const line of summary(result)) output.out(line);
|
|
902
|
+
return result.installed === false ? 1 : 0;
|
|
903
|
+
} catch (error) {
|
|
904
|
+
if (error instanceof InitError || error instanceof SynqApiError) {
|
|
905
|
+
output.err(error.message);
|
|
906
|
+
return 1;
|
|
907
|
+
}
|
|
908
|
+
output.err("Setup could not finish. Check file permissions and your package manager. If .env.local was written, use its claim link; init keeps existing scaffold files.");
|
|
909
|
+
return 1;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
function parse(argv) {
|
|
913
|
+
return parseArgs({
|
|
914
|
+
args: [...argv],
|
|
915
|
+
allowPositionals: true,
|
|
916
|
+
options: {
|
|
917
|
+
force: { type: "boolean" },
|
|
918
|
+
"no-install": { type: "boolean" },
|
|
919
|
+
stage: {
|
|
920
|
+
type: "string",
|
|
921
|
+
default: "production"
|
|
922
|
+
},
|
|
923
|
+
help: {
|
|
924
|
+
type: "boolean",
|
|
925
|
+
short: "h"
|
|
926
|
+
},
|
|
927
|
+
version: {
|
|
928
|
+
type: "boolean",
|
|
929
|
+
short: "v"
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
/** What init did, and what to do next, in the order a developer reads it. */
|
|
935
|
+
function summary(result) {
|
|
936
|
+
const { made } = result;
|
|
937
|
+
const date = new Date(made.claim.expires_at).toISOString().slice(0, 10);
|
|
938
|
+
const lines = [
|
|
939
|
+
`Made a development sign-in for ${made.org.name}.`,
|
|
940
|
+
"",
|
|
941
|
+
...result.written.map((path) => ` wrote ${path}`),
|
|
942
|
+
...result.kept.map((path) => ` kept ${path} (yours already)`)
|
|
943
|
+
];
|
|
944
|
+
if (result.devScript) lines.push(` dev "${result.devScript.from}" now serves https: "${result.devScript.to}"`);
|
|
945
|
+
if (result.installed === false) lines.push("", "Adding @synqauth/nextjs failed: add it yourself before you start the app.");
|
|
946
|
+
lines.push("", "Next:", ` 1. ${result.devCommand}`, " Its first start may ask for your password, once, to trust a local certificate.", ` 2. Open ${result.openUrl} and sign in.`, " Sign-in needs the https address. If dev serves http instead, restart it in a", " terminal and accept the prompt.", "", `Claim it by ${date} to keep it: sign up with this link and it's yours.`, ` ${made.claim.url ?? "Sign up, then claim it with SYNQ_CLAIM_TOKEN from .env.local."}`, `Until then it signs in up to ${made.limits.users} people, on localhost only.`);
|
|
947
|
+
return lines;
|
|
948
|
+
}
|
|
949
|
+
//#endregion
|
|
950
|
+
export { init as a, InitError as i, summary as n, SynqApiError as o, CLI_NAME as r, main as t };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
//#region src/api.d.ts
|
|
2
|
+
type ClaimableOrg = {
|
|
3
|
+
org: {
|
|
4
|
+
id: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
name: string;
|
|
7
|
+
};
|
|
8
|
+
issuer: string;
|
|
9
|
+
app: {
|
|
10
|
+
client_id: string;
|
|
11
|
+
client_secret: string | null;
|
|
12
|
+
redirect_uris: string[];
|
|
13
|
+
};
|
|
14
|
+
claim: {
|
|
15
|
+
token: string;
|
|
16
|
+
url: string | null;
|
|
17
|
+
expires_at: string;
|
|
18
|
+
};
|
|
19
|
+
limits: {
|
|
20
|
+
users: number;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** Why Synq didn't make one, in words for the developer. */
|
|
24
|
+
export declare class SynqApiError extends Error {
|
|
25
|
+
readonly code: string;
|
|
26
|
+
readonly status: number | undefined;
|
|
27
|
+
constructor(message: string, code: string, status?: number);
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/project.d.ts
|
|
31
|
+
type Framework = 'nextjs' | 'vite' | 'unknown';
|
|
32
|
+
type PackageManager = 'pnpm' | 'yarn' | 'bun' | 'npm';
|
|
33
|
+
type Project = {
|
|
34
|
+
dir: string;
|
|
35
|
+
/** What the project is called: its package.json's name, its scope a prefix, else its folder's. */
|
|
36
|
+
name: string;
|
|
37
|
+
framework: Framework;
|
|
38
|
+
/** A Next.js App Router project's app folder, relative to `dir`: `app` or `src/app`. */
|
|
39
|
+
appDir?: string;
|
|
40
|
+
packageManager: PackageManager;
|
|
41
|
+
/** Where its dev server listens: Next.js's 3000 and Vite's 5173 unless its dev script says. */
|
|
42
|
+
port: number;
|
|
43
|
+
/** Its package.json, as read. */
|
|
44
|
+
manifest: PackageManifest | undefined;
|
|
45
|
+
};
|
|
46
|
+
type PackageManifest = {
|
|
47
|
+
name?: string;
|
|
48
|
+
/** Corepack's field: `pnpm@10.0.0`, say. */
|
|
49
|
+
packageManager?: string;
|
|
50
|
+
scripts?: Record<string, string>;
|
|
51
|
+
dependencies?: Record<string, string>;
|
|
52
|
+
devDependencies?: Record<string, string>;
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/init.d.ts
|
|
56
|
+
/** The CLI's own name, as npm and `npx` know it: one constant, so a rename is one line. */
|
|
57
|
+
export declare const CLI_NAME = "synqauth";
|
|
58
|
+
/** Which Synq: production for everyone, the others for people working on Synq. */
|
|
59
|
+
type Stage = 'production' | 'preview' | 'staging' | 'local';
|
|
60
|
+
type InitOptions = {
|
|
61
|
+
dir: string;
|
|
62
|
+
/** Which Synq to use: production unless a developer of Synq says otherwise. */
|
|
63
|
+
stage: Stage;
|
|
64
|
+
/** For `local`: where the local stack's API is (`SYNQ_API_URL`), and the website's. */
|
|
65
|
+
env: Record<string, string | undefined>;
|
|
66
|
+
/** Make one even where .env.local has Synq's values already (they're appended again). */
|
|
67
|
+
force: boolean;
|
|
68
|
+
/** Add @synqauth/nextjs with the project's package manager. */
|
|
69
|
+
install: boolean;
|
|
70
|
+
version: string;
|
|
71
|
+
fetch?: typeof fetch;
|
|
72
|
+
/**
|
|
73
|
+
* Runs a command in the project, answering its exit code (127 when it
|
|
74
|
+
* can't start): an install, git's view of `.env.local`, in tests a fake.
|
|
75
|
+
* `quiet` runs it without the terminal's input and output.
|
|
76
|
+
*/
|
|
77
|
+
run?: (command: readonly string[], cwd: string, options?: {
|
|
78
|
+
quiet?: boolean;
|
|
79
|
+
}) => Promise<number>;
|
|
80
|
+
now?: () => Date;
|
|
81
|
+
};
|
|
82
|
+
type InitResult = {
|
|
83
|
+
project: Project;
|
|
84
|
+
made: ClaimableOrg;
|
|
85
|
+
/** Files written, relative to the project. */
|
|
86
|
+
written: string[];
|
|
87
|
+
/** Files left as they were because the project has them. */
|
|
88
|
+
kept: string[];
|
|
89
|
+
/** What changed in package.json's dev script, if anything. */
|
|
90
|
+
devScript?: {
|
|
91
|
+
from: string;
|
|
92
|
+
to: string;
|
|
93
|
+
};
|
|
94
|
+
/** Whether @synqauth/nextjs was added: true, false when it failed, undefined when not tried. */
|
|
95
|
+
installed?: boolean;
|
|
96
|
+
/** How to start the dev server, and the page to open. */
|
|
97
|
+
devCommand: string;
|
|
98
|
+
openUrl: string;
|
|
99
|
+
};
|
|
100
|
+
/** Why init stopped before making anything, in words for the developer. */
|
|
101
|
+
export declare class InitError extends Error {
|
|
102
|
+
readonly code: 'no_project' | 'not_nextjs' | 'no_app_router' | 'has_synq' | 'unsafe_file' | 'dev_script';
|
|
103
|
+
constructor(message: string, code: InitError['code']);
|
|
104
|
+
}
|
|
105
|
+
export declare function init(options: InitOptions): Promise<InitResult>;
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/cli.d.ts
|
|
108
|
+
type Output = {
|
|
109
|
+
out: (line: string) => void;
|
|
110
|
+
err: (line: string) => void;
|
|
111
|
+
};
|
|
112
|
+
export declare function main(argv: readonly string[], context?: {
|
|
113
|
+
cwd?: string;
|
|
114
|
+
env?: Record<string, string | undefined>;
|
|
115
|
+
output?: Output;
|
|
116
|
+
run?: Parameters<typeof init>[0]['run'];
|
|
117
|
+
fetch?: typeof fetch;
|
|
118
|
+
}): Promise<number>;
|
|
119
|
+
/** What init did, and what to do next, in the order a developer reads it. */
|
|
120
|
+
export declare function summary(result: InitResult): string[];
|
|
121
|
+
//#endregion
|
|
122
|
+
export type { InitOptions, InitResult, Stage };
|
package/dist/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,11 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synqauth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Synq's
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Synq's command line: npx synqauth init gives your Next.js app a working sign-in, with no account until you claim it.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"homepage": "https://synq.build",
|
|
7
6
|
"repository": {
|
|
8
7
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/aerosol-build/synq.git"
|
|
8
|
+
"url": "git+https://github.com/aerosol-build/synq.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"synqauth": "./dist/bin.js"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@synq/links": "0.0.0",
|
|
34
|
+
"@vitest/coverage-v8": "5.0.2",
|
|
35
|
+
"vitest": "5.0.2"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsdown",
|
|
39
|
+
"lint": "eslint .",
|
|
40
|
+
"typecheck": "tsc",
|
|
41
|
+
"test": "vitest run --coverage"
|
|
10
42
|
}
|
|
11
|
-
}
|
|
43
|
+
}
|