@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.
@@ -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
- redisCodeRepositoryBuilder,
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 / code repositories from
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 { clientFactory, userFactory } = createRepositoryFactories();
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 { userFactory } = createRepositoryFactories();
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
- codeRepository: async ({ config }) => {
110
- const { userFactory, codeFactory } = createRepositoryFactories();
111
- registerBuiltinAdapters({ userFactory });
112
- codeFactory.register("redis", redisCodeRepositoryBuilder);
113
- return codeFactory.create(
114
- flattenAdapterConfig(
115
- (config as AppConfig).repositories.code as { type: string } & Record<string, unknown>,
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
- * Stores module — provides the four-store user-session split + federation
124
- * token store. The standalone template uses in-memory stores by default; a
125
- * redis-backed deployment swaps this module for `@o3co/auth-provider-redis`'s
126
- * equivalent (per A4 §10 / Phase 5).
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 storesModule: Module = defineModule({
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.
@@ -1,10 +1,10 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.5.1",
3
- "@o3co/auth-provider-federation-google": "0.5.1",
4
- "@o3co/auth-provider-federation-github": "0.5.1",
5
- "@o3co/auth-provider-oauth": "0.5.1",
6
- "@o3co/auth-provider-oauth-token-exchange": "0.5.1",
7
- "@o3co/auth-provider-session": "0.5.1",
8
- "@o3co/auth-provider-foundation": "0.5.1",
9
- "@o3co/auth-provider-redis": "0.5.1"
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,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=index.test.d.mts.map
@@ -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,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=published-package.test.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"published-package.test.d.mts","sourceRoot":"","sources":["../../src/__tests__/published-package.test.mts"],"names":[],"mappings":""}