@o3co/create-auth-provider 0.5.1 → 0.5.2
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/package.json +4 -1
- package/templates/standalone/README.md +17 -0
- package/templates/standalone/config/application.conf +141 -3
- package/templates/standalone/config/clients.yaml.example +17 -1
- package/templates/standalone/docker-compose.test.yml +1 -1
- package/templates/standalone/docker-compose.yml +1 -1
- package/templates/standalone/package.json +12 -3
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +180 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +217 -8
- package/templates/standalone/src/app.mts +13 -40
- package/templates/standalone/src/buildModules.mts +120 -5
- package/templates/standalone/src/modules.mts +239 -24
- package/templates/versions.json +8 -8
- package/dist/__tests__/index.test.d.mts +0 -2
- package/dist/__tests__/index.test.d.mts.map +0 -1
- package/dist/__tests__/index.test.mjs +0 -281
- package/dist/__tests__/published-package.test.d.mts +0 -2
- package/dist/__tests__/published-package.test.d.mts.map +0 -1
- package/dist/__tests__/published-package.test.mjs +0 -73
|
@@ -24,17 +24,26 @@ import {
|
|
|
24
24
|
createKeyStoreFactory,
|
|
25
25
|
createRepositoryFactories,
|
|
26
26
|
defineModule,
|
|
27
|
+
type LifecycleRegistrar,
|
|
27
28
|
type Module,
|
|
28
29
|
registerBuiltinFederationTokenStores,
|
|
29
30
|
registerBuiltinKeyStores,
|
|
30
31
|
} from "@o3co/auth-provider-core";
|
|
31
32
|
import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
|
|
32
33
|
import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
redisFederationTokenStoreBuilder,
|
|
36
|
-
} from "@o3co/auth-provider-redis";
|
|
34
|
+
import { redisFederationTokenStoreBuilder } from "@o3co/auth-provider-redis";
|
|
35
|
+
import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
|
|
37
36
|
import { extractFederationSection } from "@o3co/auth-provider-session";
|
|
37
|
+
// Named import is required here, not default. Under `module: "nodenext"`
|
|
38
|
+
// with esModuleInterop, the default import resolves to the entire ioredis
|
|
39
|
+
// CJS module-exports namespace object (because ioredis 5.x ships
|
|
40
|
+
// `export = Redis`), so `new Redis(...)` raises TS2351 "not constructable".
|
|
41
|
+
// The 11 default-import sites elsewhere in the repo all live under
|
|
42
|
+
// `packages/redis/__tests__/` which `packages/redis/tsconfig.json`
|
|
43
|
+
// explicitly excludes from strict tsc build (vitest's vue-tsc is more
|
|
44
|
+
// permissive about CJS interop). Standalone production source is built
|
|
45
|
+
// with strict nodenext, so the named import is the right shape here.
|
|
46
|
+
import { Redis } from "ioredis";
|
|
38
47
|
|
|
39
48
|
/**
|
|
40
49
|
* Helper: turn a v0.4.x { type, [type]: {...} } adapter-config slice into the
|
|
@@ -79,15 +88,27 @@ export const keyStoreModule: Module = defineModule({
|
|
|
79
88
|
});
|
|
80
89
|
|
|
81
90
|
/**
|
|
82
|
-
* Repositories module — provides client / user
|
|
91
|
+
* Repositories module — provides client / user repositories from
|
|
83
92
|
* `config.repositories.*` slices using the built-in adapter factories.
|
|
93
|
+
*
|
|
94
|
+
* Pre-OR-9 this module also provided `codeRepository` — split out into
|
|
95
|
+
* `inMemoryCodeRepositoryModule` (memory branch) / `redisCodeRepositoryModule`
|
|
96
|
+
* (redis branch, from `@o3co/auth-provider-redis`) so the
|
|
97
|
+
* `oauth.code.adapter` switch can wire mutually-exclusive providers without
|
|
98
|
+
* a slot collision. Same pattern as the Wave-5d userSessionStores split.
|
|
84
99
|
*/
|
|
85
100
|
export const repositoriesModule: Module = defineModule({
|
|
86
101
|
name: "standalone:repositories",
|
|
87
102
|
requires: ["config"] as const,
|
|
103
|
+
// D-5 / OR-2 / IH-11: forward `lifecycleRegistrar` into the repository
|
|
104
|
+
// factories so client/user adapters can register disposal callbacks
|
|
105
|
+
// (e.g. file-watch closers). Without this, builders' `ctx.lifecycle?.register`
|
|
106
|
+
// is a no-op and the leaks remain.
|
|
107
|
+
optional: ["lifecycleRegistrar"] as const,
|
|
88
108
|
provides: {
|
|
89
|
-
clientRepository: async ({ config }) => {
|
|
90
|
-
const {
|
|
109
|
+
clientRepository: async ({ config, lifecycleRegistrar }) => {
|
|
110
|
+
const ctx = { lifecycle: lifecycleRegistrar };
|
|
111
|
+
const { clientFactory, userFactory } = createRepositoryFactories(ctx);
|
|
91
112
|
registerBuiltinAdapters({ userFactory });
|
|
92
113
|
const slice = flattenAdapterConfig(
|
|
93
114
|
(config as AppConfig).repositories.client as { type: string } & Record<string, unknown>,
|
|
@@ -97,8 +118,9 @@ export const repositoriesModule: Module = defineModule({
|
|
|
97
118
|
}
|
|
98
119
|
return clientFactory.create(slice);
|
|
99
120
|
},
|
|
100
|
-
userRepository: async ({ config }) => {
|
|
101
|
-
const {
|
|
121
|
+
userRepository: async ({ config, lifecycleRegistrar }) => {
|
|
122
|
+
const ctx = { lifecycle: lifecycleRegistrar };
|
|
123
|
+
const { userFactory } = createRepositoryFactories(ctx);
|
|
102
124
|
registerBuiltinAdapters({ userFactory });
|
|
103
125
|
return userFactory.create(
|
|
104
126
|
flattenAdapterConfig(
|
|
@@ -106,33 +128,82 @@ export const repositoriesModule: Module = defineModule({
|
|
|
106
128
|
),
|
|
107
129
|
);
|
|
108
130
|
},
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* In-memory CodeRepository module — wired by `buildModules` only when
|
|
136
|
+
* `oauth.code.adapter = "memory"`. The redis branch swaps in
|
|
137
|
+
* `redisCodeRepositoryModule` from `@o3co/auth-provider-redis` (mutually
|
|
138
|
+
* exclusive — both modules provide the same `codeRepository` slot).
|
|
139
|
+
*
|
|
140
|
+
* Per OR-9 (Wave 5d) split: pre-OR-9 the codeRepository slot was provided
|
|
141
|
+
* inside `repositoriesModule` via the AdapterFactory pattern; that path
|
|
142
|
+
* coupled the slot to `repositories.code.type` which is now superseded by
|
|
143
|
+
* `oauth.code.adapter`.
|
|
144
|
+
*/
|
|
145
|
+
export const inMemoryCodeRepositoryModule: Module = defineModule({
|
|
146
|
+
name: "standalone:in-memory-code-repository",
|
|
147
|
+
requires: ["config"] as const,
|
|
148
|
+
optional: ["lifecycleRegistrar"] as const,
|
|
149
|
+
provides: {
|
|
150
|
+
codeRepository: async ({ config, lifecycleRegistrar }) => {
|
|
151
|
+
const ctx = { lifecycle: lifecycleRegistrar };
|
|
152
|
+
const { codeFactory } = createRepositoryFactories(ctx);
|
|
153
|
+
// `codeFactory.register("memory", ...)` is wired automatically by
|
|
154
|
+
// `createRepositoryFactories` itself (see core/repositories/RepositoryFactory.mts).
|
|
155
|
+
// `registerBuiltinAdapters` from `@o3co/auth-provider-foundation` only
|
|
156
|
+
// registers the HTTP user adapter on `userFactory`; it does NOT touch
|
|
157
|
+
// `codeFactory`, so calling it here would be misleading and unnecessary.
|
|
158
|
+
//
|
|
159
|
+
// The flattened slice MAY still have `type = "redis"` when only the
|
|
160
|
+
// legacy `repositories.code.type` is set — `buildModules`' adapter
|
|
161
|
+
// resolution already chose memory by the time we get here, so
|
|
162
|
+
// override the type explicitly to keep this module robust against
|
|
163
|
+
// legacy HOCON shapes.
|
|
164
|
+
const slice = flattenAdapterConfig(
|
|
165
|
+
(config as AppConfig).repositories.code as { type: string } & Record<string, unknown>,
|
|
117
166
|
);
|
|
167
|
+
return codeFactory.create({ ...slice, type: "memory" });
|
|
118
168
|
},
|
|
119
169
|
},
|
|
120
170
|
});
|
|
121
171
|
|
|
122
172
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
173
|
+
* In-memory user-session stores module — provides the four-store
|
|
174
|
+
* user-session split (userSessionStore, sessionRPRegistry,
|
|
175
|
+
* sessionFamilyIndex, sessionFederationIndex). This module is wired by
|
|
176
|
+
* `buildModules` only when `userSessionStores.adapter = "memory"`. The
|
|
177
|
+
* Redis branch swaps in `redisSessionStoresModule` from
|
|
178
|
+
* `@o3co/auth-provider-redis`.
|
|
179
|
+
*
|
|
180
|
+
* Pre-Wave-5d this module also provided `federationTokenStore` (now
|
|
181
|
+
* extracted into a separate always-wired module so the redis session
|
|
182
|
+
* branch doesn't drop the federation-token-store provider — Copilot
|
|
183
|
+
* review on PR #121).
|
|
127
184
|
*/
|
|
128
|
-
export const
|
|
129
|
-
name: "standalone:stores",
|
|
130
|
-
requires: ["config"] as const,
|
|
185
|
+
export const inMemorySessionStoresModule: Module = defineModule({
|
|
186
|
+
name: "standalone:in-memory-session-stores",
|
|
131
187
|
provides: {
|
|
132
188
|
userSessionStore: () => createInMemoryUserSessionStore(),
|
|
133
189
|
sessionRPRegistry: () => createInMemorySessionRPRegistry(),
|
|
134
190
|
sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
|
|
135
191
|
sessionFederationIndex: () => createInMemorySessionFederationIndex(),
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Federation token store module — always wired (independent of the
|
|
197
|
+
* `userSessionStores.adapter` switch). Pre-Wave-5d this slot was bundled
|
|
198
|
+
* into the larger `storesModule`; that meant the redis session-stores
|
|
199
|
+
* branch dropped the federation-token-store provider entirely and boot
|
|
200
|
+
* failed on the missing component (Copilot review on PR #121). Splitting
|
|
201
|
+
* the slot out lets both adapters reuse it unchanged.
|
|
202
|
+
*/
|
|
203
|
+
export const federationTokenStoreModule: Module = defineModule({
|
|
204
|
+
name: "standalone:federation-token-store",
|
|
205
|
+
requires: ["config"] as const,
|
|
206
|
+
provides: {
|
|
136
207
|
federationTokenStore: async ({ config }) => {
|
|
137
208
|
const factory = createFederationTokenStoreFactory();
|
|
138
209
|
registerBuiltinFederationTokenStores(factory);
|
|
@@ -147,6 +218,150 @@ export const storesModule: Module = defineModule({
|
|
|
147
218
|
},
|
|
148
219
|
});
|
|
149
220
|
|
|
221
|
+
/**
|
|
222
|
+
* @deprecated since Wave 5d (F4 PR2): split into `inMemorySessionStoresModule`
|
|
223
|
+
* + `federationTokenStoreModule`. Re-exported for backward compatibility
|
|
224
|
+
* with consumers that imported `storesModule` from this file. New code
|
|
225
|
+
* should use the two split modules directly.
|
|
226
|
+
*/
|
|
227
|
+
export const storesModule: Module = defineModule({
|
|
228
|
+
name: "standalone:stores",
|
|
229
|
+
requires: ["config"] as const,
|
|
230
|
+
provides: {
|
|
231
|
+
...inMemorySessionStoresModule.provides,
|
|
232
|
+
...federationTokenStoreModule.provides,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Shared ioredis clients module — opens ONE long-lived ioredis connection
|
|
238
|
+
* per replica, wraps it via `makeIoredisClients()` (returns 9 typed
|
|
239
|
+
* per-purpose clients), and exposes the slots consumed by the standalone's
|
|
240
|
+
* Redis-backed adapters (refresh-token-family + 4 user-session stores +
|
|
241
|
+
* rate limiter).
|
|
242
|
+
*
|
|
243
|
+
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
244
|
+
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
245
|
+
* This module consolidates them — `makeIoredisClients(io)` was always
|
|
246
|
+
* designed to derive multiple per-purpose typed clients from a single
|
|
247
|
+
* connection; using one socket realises that intent and minimises
|
|
248
|
+
* connection-pool pressure on the upstream Redis.
|
|
249
|
+
*
|
|
250
|
+
* Connection-config: reuses `refreshTokenFamilyStore.redis.{url, password}`
|
|
251
|
+
* (declared in `fullSectionsSchema` by D-2 v2 §1). No new top-level config
|
|
252
|
+
* key is introduced — the connection is shared so its config is too. Future
|
|
253
|
+
* splits (per-store distinct Redis instances) are operator-side via custom
|
|
254
|
+
* composition roots, not standalone configuration.
|
|
255
|
+
*
|
|
256
|
+
* Fails fast (throws) when the Redis URL is absent. The HOCON default in
|
|
257
|
+
* `application.conf` covers single-instance / dev scenarios; an operator
|
|
258
|
+
* who deliberately removes the section in production sees an explicit
|
|
259
|
+
* error instead of a silent localhost fallback (the multi-replica failure
|
|
260
|
+
* mode OR-1 was supposed to close).
|
|
261
|
+
*
|
|
262
|
+
* Lifecycle: registers `io.quit()` once with the boot-planner-pre-seeded
|
|
263
|
+
* `lifecycleRegistrar` (D-5 ComponentMap slot) so `handle.dispose()` quits
|
|
264
|
+
* the single connection cleanly.
|
|
265
|
+
*
|
|
266
|
+
* Conditional inclusion: `buildModules` only adds this module to the
|
|
267
|
+
* manifest when at least one Redis-backed adapter is selected
|
|
268
|
+
* (`userSessionStores.adapter = "redis"` OR `rateLimiter.adapter = "redis"`
|
|
269
|
+
* — refresh-token-family is always Redis-backed in production). Adding the
|
|
270
|
+
* module unconditionally would open an ioredis socket for memory-only
|
|
271
|
+
* deployments.
|
|
272
|
+
*/
|
|
273
|
+
export const standaloneRedisClientsModule: Module = defineModule({
|
|
274
|
+
name: "standalone:redis-clients",
|
|
275
|
+
requires: ["config"] as const,
|
|
276
|
+
optional: ["lifecycleRegistrar"] as const,
|
|
277
|
+
provides: {
|
|
278
|
+
refreshTokenFamilyClient: async ({ config, lifecycleRegistrar }) => {
|
|
279
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar).refreshTokenFamilyClient;
|
|
280
|
+
},
|
|
281
|
+
userSessionStoreClient: async ({ config, lifecycleRegistrar }) => {
|
|
282
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar).userSessionStoreClient;
|
|
283
|
+
},
|
|
284
|
+
sessionRPRegistryClient: async ({ config, lifecycleRegistrar }) => {
|
|
285
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar).sessionRPRegistryClient;
|
|
286
|
+
},
|
|
287
|
+
sessionFamilyIndexClient: async ({ config, lifecycleRegistrar }) => {
|
|
288
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar).sessionFamilyIndexClient;
|
|
289
|
+
},
|
|
290
|
+
sessionFederationIndexClient: async ({ config, lifecycleRegistrar }) => {
|
|
291
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar)
|
|
292
|
+
.sessionFederationIndexClient;
|
|
293
|
+
},
|
|
294
|
+
rateLimiterClient: async ({ config, lifecycleRegistrar }) => {
|
|
295
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar).rateLimiterClient;
|
|
296
|
+
},
|
|
297
|
+
// OR-9: code-repository client wired off the same shared ioredis
|
|
298
|
+
// socket. `redisCodeRepositoryModule` consumes this slot when
|
|
299
|
+
// `oauth.code.adapter = "redis"`. Codes are short-TTL (60-600s)
|
|
300
|
+
// high-volume — sharing the connection avoids opening a separate
|
|
301
|
+
// socket while the per-purpose typed wrapper keeps the Redis
|
|
302
|
+
// command surface consumed by RedisCodeRepository explicit.
|
|
303
|
+
codeRepositoryClient: async ({ config, lifecycleRegistrar }) => {
|
|
304
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar).codeRepositoryClient;
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Module-scoped cache so each `provides.*` factory invocation reuses the
|
|
310
|
+
// same `Redis` instance (and its `makeIoredisClients()` derivation) within
|
|
311
|
+
// a single createApp() invocation. The boot planner calls each `provides`
|
|
312
|
+
// in dependency order; without this, every consumed slot would create its
|
|
313
|
+
// own Redis socket, defeating the unification purpose.
|
|
314
|
+
//
|
|
315
|
+
// Cache key: the `lifecycleRegistrar` IDENTITY (which is per-boot — each
|
|
316
|
+
// `createApp()` invocation seeds a fresh registrar via the boot planner).
|
|
317
|
+
// Keying solely on `config` would incorrectly share connections across
|
|
318
|
+
// boots when the same config object is reused with a new registrar (and
|
|
319
|
+
// only the FIRST boot's registrar would receive disposal — Copilot review
|
|
320
|
+
// on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
|
|
321
|
+
// that don't seed it), each call creates a fresh client; tests are
|
|
322
|
+
// isolated and don't need cross-slot sharing.
|
|
323
|
+
const clientsCache = new WeakMap<LifecycleRegistrar, ReturnType<typeof makeIoredisClients>>();
|
|
324
|
+
function getOrCreateClients(
|
|
325
|
+
config: AppConfig,
|
|
326
|
+
lifecycleRegistrar: LifecycleRegistrar | undefined,
|
|
327
|
+
): ReturnType<typeof makeIoredisClients> {
|
|
328
|
+
const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
|
|
329
|
+
if (cached) return cached;
|
|
330
|
+
|
|
331
|
+
const cfg = config.refreshTokenFamilyStore?.redis;
|
|
332
|
+
if (typeof cfg?.url !== "string" || cfg.url.length === 0) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
"standaloneRedisClientsModule: `refreshTokenFamilyStore.redis.url` is required when any " +
|
|
335
|
+
"Redis-backed adapter is selected. Set REFRESH_TOKEN_FAMILY_STORE_REDIS_URL or " +
|
|
336
|
+
"restore the `refreshTokenFamilyStore.redis` block in application.conf. Multi-replica " +
|
|
337
|
+
"deployments require a shared Redis 7.2+ instance.",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const password = typeof cfg.password === "string" ? cfg.password : undefined;
|
|
341
|
+
|
|
342
|
+
// `lazyConnect: false` is the ioredis 5.x default; the explicit option
|
|
343
|
+
// documents the boot-time-connect contract so a future ioredis version
|
|
344
|
+
// flip does not silently change the failure mode.
|
|
345
|
+
const io = new Redis(cfg.url, { password, lazyConnect: false });
|
|
346
|
+
|
|
347
|
+
// Attach error handler so unhandled "error" events from ioredis 5.x do
|
|
348
|
+
// not crash the Node.js process. Initial connection failures surface
|
|
349
|
+
// here; downstream adapter operations then fail visibly. TODO(D-4):
|
|
350
|
+
// swap `console.error` for the Logger interface once a v0.5.2 polish
|
|
351
|
+
// PR lands.
|
|
352
|
+
io.on("error", (err: unknown) => {
|
|
353
|
+
console.error("[standalone:redis-clients] ioredis error", err);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
lifecycleRegistrar?.register(async () => {
|
|
357
|
+
await io.quit();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const clients = makeIoredisClients(io);
|
|
361
|
+
if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
|
|
362
|
+
return clients;
|
|
363
|
+
}
|
|
364
|
+
|
|
150
365
|
/**
|
|
151
366
|
* Google federation config bridge — supplies the typed `googleFederationConfig`
|
|
152
367
|
* ComponentMap slot from the `config.federations.google` slice.
|
package/templates/versions.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth-provider-core": "0.5.
|
|
3
|
-
"@o3co/auth-provider-federation-google": "0.5.
|
|
4
|
-
"@o3co/auth-provider-federation-github": "0.5.
|
|
5
|
-
"@o3co/auth-provider-oauth": "0.5.
|
|
6
|
-
"@o3co/auth-provider-oauth-token-exchange": "0.5.
|
|
7
|
-
"@o3co/auth-provider-session": "0.5.
|
|
8
|
-
"@o3co/auth-provider-foundation": "0.5.
|
|
9
|
-
"@o3co/auth-provider-redis": "0.5.
|
|
2
|
+
"@o3co/auth-provider-core": "0.5.2",
|
|
3
|
+
"@o3co/auth-provider-federation-google": "0.5.2",
|
|
4
|
+
"@o3co/auth-provider-federation-github": "0.5.2",
|
|
5
|
+
"@o3co/auth-provider-oauth": "0.5.2",
|
|
6
|
+
"@o3co/auth-provider-oauth-token-exchange": "0.5.2",
|
|
7
|
+
"@o3co/auth-provider-session": "0.5.2",
|
|
8
|
+
"@o3co/auth-provider-foundation": "0.5.2",
|
|
9
|
+
"@o3co/auth-provider-redis": "0.5.2"
|
|
10
10
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.test.d.mts","sourceRoot":"","sources":["../../src/__tests__/index.test.mts"],"names":[],"mappings":""}
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join, posix, win32 } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { isValidDirName, isValidProjectName, main, scaffold } from "../index.mjs";
|
|
6
|
-
import { shouldCopyTemplateEntry } from "../internal/template-filter.mjs";
|
|
7
|
-
// Both POSIX and Windows separators are exercised explicitly so the suite
|
|
8
|
-
// validates the separator-relative segment logic regardless of the host
|
|
9
|
-
// platform. (`path.sep` is the production default; tests pass it explicitly.)
|
|
10
|
-
const platforms = [
|
|
11
|
-
{
|
|
12
|
-
name: "POSIX-style paths",
|
|
13
|
-
sep: posix.sep,
|
|
14
|
-
installRoot: "/Users/x/.npm/_npx/abc/node_modules/@o3co/create-auth-provider/templates/standalone",
|
|
15
|
-
localRoot: "/repo/templates/standalone",
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
name: "Windows-style paths",
|
|
19
|
-
sep: win32.sep,
|
|
20
|
-
installRoot: "C:\\Users\\x\\AppData\\Roaming\\npm-cache\\_npx\\abc\\node_modules\\@o3co\\create-auth-provider\\templates\\standalone",
|
|
21
|
-
localRoot: "C:\\repo\\templates\\standalone",
|
|
22
|
-
},
|
|
23
|
-
];
|
|
24
|
-
describe.each(platforms)("shouldCopyTemplateEntry on $name", ({ sep, installRoot, localRoot }) => {
|
|
25
|
-
const joinSegments = (base, ...rest) => [base, ...rest].join(sep);
|
|
26
|
-
it("includes the template root itself", () => {
|
|
27
|
-
expect(shouldCopyTemplateEntry(installRoot, installRoot, sep)).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
it("includes a file directly under the template root even when ancestor path contains 'node_modules'", () => {
|
|
30
|
-
// Regression for v0.5.0 npx install bug: when the package is installed
|
|
31
|
-
// at .../node_modules/@o3co/create-auth-provider/..., the previous filter
|
|
32
|
-
// checked every segment of the absolute source path and excluded
|
|
33
|
-
// everything, so cpSync copied no files.
|
|
34
|
-
expect(shouldCopyTemplateEntry(joinSegments(installRoot, "package.json"), installRoot, sep)).toBe(true);
|
|
35
|
-
});
|
|
36
|
-
it("includes nested template files even when ancestor path contains 'node_modules' or 'dist'", () => {
|
|
37
|
-
expect(shouldCopyTemplateEntry(joinSegments(installRoot, "src", "app.mts"), installRoot, sep)).toBe(true);
|
|
38
|
-
expect(shouldCopyTemplateEntry(joinSegments(installRoot, "config", "application.conf"), installRoot, sep)).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
it("excludes node_modules subdirectories that live INSIDE the template root", () => {
|
|
41
|
-
expect(shouldCopyTemplateEntry(joinSegments(localRoot, "node_modules", "foo", "index.js"), localRoot, sep)).toBe(false);
|
|
42
|
-
});
|
|
43
|
-
it("excludes dist subdirectories that live INSIDE the template root", () => {
|
|
44
|
-
expect(shouldCopyTemplateEntry(joinSegments(localRoot, "dist", "index.mjs"), localRoot, sep)).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
describe("scaffold", () => {
|
|
48
|
-
let tempDir;
|
|
49
|
-
beforeEach(() => {
|
|
50
|
-
tempDir = mkdtempSync(join(tmpdir(), "create-auth-provider-test-"));
|
|
51
|
-
});
|
|
52
|
-
afterEach(() => {
|
|
53
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
54
|
-
});
|
|
55
|
-
it("copies template files to target directory", () => {
|
|
56
|
-
const targetDir = join(tempDir, "my-auth");
|
|
57
|
-
scaffold(targetDir, "my-auth");
|
|
58
|
-
expect(existsSync(join(targetDir, "package.json"))).toBe(true);
|
|
59
|
-
expect(existsSync(join(targetDir, "tsconfig.json"))).toBe(true);
|
|
60
|
-
expect(existsSync(join(targetDir, "src", "app.mts"))).toBe(true);
|
|
61
|
-
expect(existsSync(join(targetDir, "config", "application.conf"))).toBe(true);
|
|
62
|
-
expect(existsSync(join(targetDir, "config", "development.conf"))).toBe(true);
|
|
63
|
-
expect(existsSync(join(targetDir, "config", "production.conf"))).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
it("rewrites package.json name to project name", () => {
|
|
66
|
-
const targetDir = join(tempDir, "my-auth");
|
|
67
|
-
scaffold(targetDir, "my-auth");
|
|
68
|
-
const pkg = JSON.parse(readFileSync(join(targetDir, "package.json"), "utf-8"));
|
|
69
|
-
expect(pkg.name).toBe("my-auth");
|
|
70
|
-
});
|
|
71
|
-
it("replaces all workspace:* dependencies with caret versions", () => {
|
|
72
|
-
const targetDir = join(tempDir, "my-auth");
|
|
73
|
-
scaffold(targetDir, "my-auth");
|
|
74
|
-
const pkg = JSON.parse(readFileSync(join(targetDir, "package.json"), "utf-8"));
|
|
75
|
-
// No workspace:* should remain anywhere
|
|
76
|
-
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
77
|
-
const deps = pkg[section];
|
|
78
|
-
if (!deps)
|
|
79
|
-
continue;
|
|
80
|
-
for (const [name, version] of Object.entries(deps)) {
|
|
81
|
-
expect(version, `${section}.${name} should not be workspace:*`).not.toBe("workspace:*");
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
// Specific checks
|
|
85
|
-
expect(pkg.dependencies["@o3co/auth-provider-core"]).toMatch(/^\^/);
|
|
86
|
-
expect(pkg.dependencies["@o3co/auth-provider-federation-google"]).toMatch(/^\^/);
|
|
87
|
-
expect(pkg.dependencies["@o3co/auth-provider-federation-github"]).toBeUndefined();
|
|
88
|
-
expect(pkg.dependencies["@o3co/auth-provider-foundation"]).toMatch(/^\^/);
|
|
89
|
-
});
|
|
90
|
-
it("removes private field from package.json", () => {
|
|
91
|
-
const targetDir = join(tempDir, "my-auth");
|
|
92
|
-
scaffold(targetDir, "my-auth");
|
|
93
|
-
const pkg = JSON.parse(readFileSync(join(targetDir, "package.json"), "utf-8"));
|
|
94
|
-
expect(pkg.private).toBeUndefined();
|
|
95
|
-
});
|
|
96
|
-
it("writes scoped project name verbatim into package.json", () => {
|
|
97
|
-
const targetDir = join(tempDir, "auth.provider");
|
|
98
|
-
scaffold(targetDir, "@piratis-blossoms/auth.provider");
|
|
99
|
-
const pkg = JSON.parse(readFileSync(join(targetDir, "package.json"), "utf-8"));
|
|
100
|
-
expect(pkg.name).toBe("@piratis-blossoms/auth.provider");
|
|
101
|
-
});
|
|
102
|
-
it("ships versions for every workspace auth-provider package, in sync with each package.json", () => {
|
|
103
|
-
const versions = JSON.parse(readFileSync(join("templates", "versions.json"), "utf-8"));
|
|
104
|
-
const expectedPackages = {
|
|
105
|
-
"@o3co/auth-provider-core": "../packages/core/package.json",
|
|
106
|
-
"@o3co/auth-provider-federation-github": "../packages/federation-github/package.json",
|
|
107
|
-
"@o3co/auth-provider-federation-google": "../packages/federation-google/package.json",
|
|
108
|
-
"@o3co/auth-provider-foundation": "../packages/foundation/package.json",
|
|
109
|
-
"@o3co/auth-provider-oauth": "../packages/oauth/package.json",
|
|
110
|
-
"@o3co/auth-provider-oauth-token-exchange": "../packages/oauth-token-exchange/package.json",
|
|
111
|
-
"@o3co/auth-provider-redis": "../packages/redis/package.json",
|
|
112
|
-
"@o3co/auth-provider-session": "../packages/session/package.json",
|
|
113
|
-
};
|
|
114
|
-
for (const [name, pkgPath] of Object.entries(expectedPackages)) {
|
|
115
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
116
|
-
expect(versions[name], `versions.json missing entry for ${name}`).toBeDefined();
|
|
117
|
-
expect(versions[name], `versions.json[${name}] out of sync with ${pkgPath}`).toBe(pkg.version);
|
|
118
|
-
}
|
|
119
|
-
expect(Object.keys(versions).sort()).toEqual(Object.keys(expectedPackages).sort());
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
describe("isValidProjectName", () => {
|
|
123
|
-
it.each([
|
|
124
|
-
["my-auth"],
|
|
125
|
-
["auth.provider"],
|
|
126
|
-
["a"],
|
|
127
|
-
["foo_bar~baz.1"],
|
|
128
|
-
["@piratis-blossoms/auth.provider"],
|
|
129
|
-
["@foo-bar/baz_qux~1"],
|
|
130
|
-
])("accepts %s", (name) => {
|
|
131
|
-
expect(isValidProjectName(name)).toBe(true);
|
|
132
|
-
});
|
|
133
|
-
it.each([
|
|
134
|
-
[""],
|
|
135
|
-
["."],
|
|
136
|
-
[".."],
|
|
137
|
-
["UPPER"],
|
|
138
|
-
["with space"],
|
|
139
|
-
["with/slash"],
|
|
140
|
-
["with\\back"],
|
|
141
|
-
["@"],
|
|
142
|
-
["@/"],
|
|
143
|
-
["@scope"],
|
|
144
|
-
["@/pkg"],
|
|
145
|
-
["@scope/"],
|
|
146
|
-
["@scope//pkg"],
|
|
147
|
-
["@SCOPE/pkg"],
|
|
148
|
-
["a".repeat(215)],
|
|
149
|
-
])("rejects %s", (name) => {
|
|
150
|
-
expect(isValidProjectName(name)).toBe(false);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
describe("isValidDirName", () => {
|
|
154
|
-
it.each([["my-auth"], ["auth.provider"], ["a"], ["foo_bar~baz.1"]])("accepts %s", (name) => {
|
|
155
|
-
expect(isValidDirName(name)).toBe(true);
|
|
156
|
-
});
|
|
157
|
-
it.each([
|
|
158
|
-
[""],
|
|
159
|
-
["."],
|
|
160
|
-
[".."],
|
|
161
|
-
["@scope/pkg"],
|
|
162
|
-
["with/slash"],
|
|
163
|
-
["with\\back"],
|
|
164
|
-
["@piratis-blossoms"],
|
|
165
|
-
["UPPER"],
|
|
166
|
-
["a".repeat(215)],
|
|
167
|
-
])("rejects %s", (name) => {
|
|
168
|
-
expect(isValidDirName(name)).toBe(false);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
describe("main (argv parsing and directory derivation)", () => {
|
|
172
|
-
let cwdBackup;
|
|
173
|
-
let workdir;
|
|
174
|
-
let argvBackup;
|
|
175
|
-
beforeEach(() => {
|
|
176
|
-
cwdBackup = process.cwd();
|
|
177
|
-
workdir = mkdtempSync(join(tmpdir(), "create-auth-provider-main-"));
|
|
178
|
-
process.chdir(workdir);
|
|
179
|
-
argvBackup = process.argv;
|
|
180
|
-
});
|
|
181
|
-
afterEach(() => {
|
|
182
|
-
process.chdir(cwdBackup);
|
|
183
|
-
process.argv = argvBackup;
|
|
184
|
-
rmSync(workdir, { recursive: true, force: true });
|
|
185
|
-
});
|
|
186
|
-
const runMain = (args) => {
|
|
187
|
-
process.argv = ["node", "cli", ...args];
|
|
188
|
-
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code) => {
|
|
189
|
-
throw new Error(`__exit__:${code ?? 0}`);
|
|
190
|
-
}));
|
|
191
|
-
const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
192
|
-
const logSpy = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
193
|
-
let exitCode = 0;
|
|
194
|
-
try {
|
|
195
|
-
main();
|
|
196
|
-
}
|
|
197
|
-
catch (e) {
|
|
198
|
-
const m = /__exit__:(\d+)/.exec(e.message);
|
|
199
|
-
exitCode = m ? Number(m[1]) : null;
|
|
200
|
-
}
|
|
201
|
-
const stderr = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
202
|
-
exitSpy.mockRestore();
|
|
203
|
-
errSpy.mockRestore();
|
|
204
|
-
logSpy.mockRestore();
|
|
205
|
-
return { exitCode, stderr };
|
|
206
|
-
};
|
|
207
|
-
// Positive: unscoped, no --dir
|
|
208
|
-
it("unscoped name: dir = name, pkg.name = name", () => {
|
|
209
|
-
const r = runMain(["my-auth"]);
|
|
210
|
-
expect(r.exitCode).toBe(0);
|
|
211
|
-
const pkg = JSON.parse(readFileSync(join(workdir, "my-auth", "package.json"), "utf-8"));
|
|
212
|
-
expect(pkg.name).toBe("my-auth");
|
|
213
|
-
});
|
|
214
|
-
// Positive: scoped, no --dir
|
|
215
|
-
it("scoped name: dir = pkg part, pkg.name = full scoped", () => {
|
|
216
|
-
const r = runMain(["@piratis-blossoms/auth.provider"]);
|
|
217
|
-
expect(r.exitCode).toBe(0);
|
|
218
|
-
const pkg = JSON.parse(readFileSync(join(workdir, "auth.provider", "package.json"), "utf-8"));
|
|
219
|
-
expect(pkg.name).toBe("@piratis-blossoms/auth.provider");
|
|
220
|
-
});
|
|
221
|
-
// Positive: scoped + --dir space
|
|
222
|
-
it("scoped name with --dir <val>: dir = val, pkg.name = full scoped", () => {
|
|
223
|
-
const r = runMain(["@piratis-blossoms/auth.provider", "--dir", "provider"]);
|
|
224
|
-
expect(r.exitCode).toBe(0);
|
|
225
|
-
const pkg = JSON.parse(readFileSync(join(workdir, "provider", "package.json"), "utf-8"));
|
|
226
|
-
expect(pkg.name).toBe("@piratis-blossoms/auth.provider");
|
|
227
|
-
});
|
|
228
|
-
// Positive: scoped + --dir= equals form
|
|
229
|
-
it("scoped name with --dir=<val>: dir = val, pkg.name = full scoped", () => {
|
|
230
|
-
const r = runMain(["@piratis-blossoms/auth.provider", "--dir=provider2"]);
|
|
231
|
-
expect(r.exitCode).toBe(0);
|
|
232
|
-
const pkg = JSON.parse(readFileSync(join(workdir, "provider2", "package.json"), "utf-8"));
|
|
233
|
-
expect(pkg.name).toBe("@piratis-blossoms/auth.provider");
|
|
234
|
-
});
|
|
235
|
-
// Positive: unscoped + --dir
|
|
236
|
-
it("unscoped name with --dir <val>: dir = val, pkg.name = unscoped", () => {
|
|
237
|
-
const r = runMain(["my-auth", "--dir", "custom"]);
|
|
238
|
-
expect(r.exitCode).toBe(0);
|
|
239
|
-
const pkg = JSON.parse(readFileSync(join(workdir, "custom", "package.json"), "utf-8"));
|
|
240
|
-
expect(pkg.name).toBe("my-auth");
|
|
241
|
-
});
|
|
242
|
-
// Positive: flags may come before the positional
|
|
243
|
-
it("flags before positional: --dir custom my-auth", () => {
|
|
244
|
-
const r = runMain(["--dir", "custom", "my-auth"]);
|
|
245
|
-
expect(r.exitCode).toBe(0);
|
|
246
|
-
const pkg = JSON.parse(readFileSync(join(workdir, "custom", "package.json"), "utf-8"));
|
|
247
|
-
expect(pkg.name).toBe("my-auth");
|
|
248
|
-
});
|
|
249
|
-
// Negative cases
|
|
250
|
-
it.each([
|
|
251
|
-
{ case: "no args", args: [] },
|
|
252
|
-
{ case: "two positionals", args: ["foo", "bar"] },
|
|
253
|
-
{ case: "dot", args: ["."] },
|
|
254
|
-
{ case: "dotdot", args: [".."] },
|
|
255
|
-
{ case: "backslash in name", args: ["back\\slash"] },
|
|
256
|
-
{ case: "empty scope", args: ["@/pkg"] },
|
|
257
|
-
{ case: "empty pkg", args: ["@scope/"] },
|
|
258
|
-
{ case: "double slash", args: ["@scope//pkg"] },
|
|
259
|
-
{ case: "name too long", args: ["a".repeat(215)] },
|
|
260
|
-
{ case: "--dir invalid (dot)", args: ["foo", "--dir", "."] },
|
|
261
|
-
{ case: "--dir invalid (slash)", args: ["foo", "--dir", "a/b"] },
|
|
262
|
-
{ case: "--dir invalid (at)", args: ["foo", "--dir", "@foo"] },
|
|
263
|
-
{ case: "--dir invalid (back)", args: ["foo", "--dir", "a\\b"] },
|
|
264
|
-
{ case: "--dir empty space form", args: ["foo", "--dir", ""] },
|
|
265
|
-
{ case: "--dir empty equals form", args: ["foo", "--dir="] },
|
|
266
|
-
{ case: "--dir missing value", args: ["foo", "--dir"] },
|
|
267
|
-
{ case: "--dir duplicated", args: ["foo", "--dir", "a", "--dir", "b"] },
|
|
268
|
-
{ case: "unknown flag", args: ["foo", "--unknown"] },
|
|
269
|
-
{ case: "literal double-dash", args: ["foo", "--"] },
|
|
270
|
-
])("rejects: $case", ({ args }) => {
|
|
271
|
-
const r = runMain(args);
|
|
272
|
-
expect(r.exitCode).toBe(1);
|
|
273
|
-
expect(r.stderr.length).toBeGreaterThan(0);
|
|
274
|
-
});
|
|
275
|
-
it("target directory already exists", () => {
|
|
276
|
-
mkdirSync(join(workdir, "foo"));
|
|
277
|
-
const r = runMain(["foo"]);
|
|
278
|
-
expect(r.exitCode).toBe(1);
|
|
279
|
-
expect(r.stderr.length).toBeGreaterThan(0);
|
|
280
|
-
});
|
|
281
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"published-package.test.d.mts","sourceRoot":"","sources":["../../src/__tests__/published-package.test.mts"],"names":[],"mappings":""}
|