handoff-app 2.0.0-2 → 2.0.0-3

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.
Files changed (161) hide show
  1. package/README.md +140 -83
  2. package/dist/app-builder/build.js +21 -4
  3. package/dist/app-builder/client-config.js +9 -8
  4. package/dist/cli/auth/browser.d.ts +2 -0
  5. package/dist/cli/auth/browser.js +33 -0
  6. package/dist/cli/auth/device.d.ts +11 -0
  7. package/dist/cli/auth/device.js +155 -0
  8. package/dist/cli/auth/store.d.ts +29 -0
  9. package/dist/cli/auth/store.js +146 -0
  10. package/dist/cli/scaffold.js +20 -154
  11. package/dist/commands/index.js +4 -0
  12. package/dist/commands/login/index.d.ts +8 -0
  13. package/dist/commands/login/index.js +53 -0
  14. package/dist/commands/logout/index.d.ts +7 -0
  15. package/dist/commands/logout/index.js +54 -0
  16. package/dist/config/entries.d.ts +47 -0
  17. package/dist/config/entries.js +162 -0
  18. package/dist/config/index.d.ts +2 -0
  19. package/dist/config/index.js +5 -1
  20. package/dist/config/runtime.d.ts +9 -9
  21. package/dist/config/runtime.js +15 -15
  22. package/dist/registry/auth/access-tokens.d.ts +46 -0
  23. package/dist/registry/auth/access-tokens.js +165 -0
  24. package/dist/registry/auth/action-tokens.d.ts +66 -0
  25. package/dist/registry/auth/action-tokens.js +178 -0
  26. package/dist/registry/auth/crypto.d.ts +4 -0
  27. package/dist/registry/auth/crypto.js +16 -0
  28. package/dist/registry/auth/database.d.ts +7 -0
  29. package/dist/registry/auth/database.js +22 -0
  30. package/dist/registry/auth/device-authorizations.d.ts +51 -0
  31. package/dist/registry/auth/device-authorizations.js +170 -0
  32. package/dist/registry/auth/gravatar.d.ts +1 -0
  33. package/dist/registry/auth/gravatar.js +8 -0
  34. package/dist/registry/auth/index.d.ts +11 -0
  35. package/dist/registry/auth/index.js +72 -0
  36. package/dist/registry/auth/installation.d.ts +42 -0
  37. package/dist/registry/auth/installation.js +105 -0
  38. package/dist/registry/auth/password.d.ts +12 -0
  39. package/dist/registry/auth/password.js +58 -0
  40. package/dist/registry/auth/rate-limits.d.ts +22 -0
  41. package/dist/registry/auth/rate-limits.js +75 -0
  42. package/dist/registry/auth/types.d.ts +26 -0
  43. package/dist/registry/auth/types.js +9 -0
  44. package/dist/registry/auth/user-record.d.ts +13 -0
  45. package/dist/registry/auth/user-record.js +16 -0
  46. package/dist/registry/auth/users.d.ts +31 -0
  47. package/dist/registry/auth/users.js +135 -0
  48. package/dist/registry/auth/validation.d.ts +3 -0
  49. package/dist/registry/auth/validation.js +28 -0
  50. package/dist/registry/checkout/assets.js +4 -2
  51. package/dist/registry/checkout/index.d.ts +1 -1
  52. package/dist/registry/checkout/index.js +101 -23
  53. package/dist/registry/checkout/tokens.js +4 -2
  54. package/dist/registry/connection.d.ts +9 -0
  55. package/dist/registry/connection.js +29 -1
  56. package/dist/registry/db/driver.d.ts +5 -4
  57. package/dist/registry/db/driver.js +5 -4
  58. package/dist/registry/db/index.d.ts +1 -1
  59. package/dist/registry/db/index.js +7 -1
  60. package/dist/registry/db/schema.d.ts +2521 -750
  61. package/dist/registry/db/schema.js +126 -1
  62. package/dist/registry/publish/assets.js +1 -1
  63. package/dist/registry/publish/errors.js +3 -3
  64. package/dist/registry/publish/index.d.ts +1 -1
  65. package/dist/registry/publish/index.js +9 -9
  66. package/dist/registry/publish/tokens.js +1 -1
  67. package/dist/types/config.d.ts +4 -1
  68. package/docs/cli.md +4 -0
  69. package/drizzle/0005_add_registry_auth.sql +95 -0
  70. package/drizzle/meta/0005_snapshot.json +1926 -0
  71. package/drizzle/meta/_journal.json +8 -1
  72. package/package.json +5 -4
  73. package/src/app/components/Auth/AccountLayout.tsx +87 -0
  74. package/src/app/components/Auth/AuthControls.tsx +99 -0
  75. package/src/app/components/Auth/AuthShell.tsx +73 -0
  76. package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
  77. package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
  78. package/src/app/components/Auth/api.ts +20 -0
  79. package/src/app/components/Layout/Header.tsx +2 -0
  80. package/src/app/components/Navigation/MobileNav.tsx +2 -0
  81. package/src/app/lib/auth/api.ts +113 -0
  82. package/src/app/lib/auth/config.ts +175 -0
  83. package/src/app/lib/auth/email.ts +34 -0
  84. package/src/app/lib/docs-api/runtime-config.ts +0 -14
  85. package/src/app/lib/registry-api/auth.ts +29 -46
  86. package/src/app/lib/registry-api/errors.ts +2 -2
  87. package/src/app/lib/registry-api/handler.ts +19 -14
  88. package/src/app/lib/registry-api/token-transfer.ts +1 -1
  89. package/src/app/lib/registry-api/transfer.ts +4 -4
  90. package/src/app/next.config.mjs +12 -3
  91. package/src/app/pages/_app.tsx +10 -4
  92. package/src/app/pages/account/index.tsx +153 -0
  93. package/src/app/pages/account/tokens.tsx +230 -0
  94. package/src/app/pages/account/users.tsx +310 -0
  95. package/src/app/pages/api/account/index.ts +26 -0
  96. package/src/app/pages/api/account/tokens/[id].ts +17 -0
  97. package/src/app/pages/api/account/tokens/index.ts +44 -0
  98. package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
  99. package/src/app/pages/api/admin/users/index.ts +53 -0
  100. package/src/app/pages/api/auth/[...nextauth].ts +4 -0
  101. package/src/app/pages/api/auth/request-reset.ts +53 -0
  102. package/src/app/pages/api/auth/reset-password.ts +29 -0
  103. package/src/app/pages/api/install.ts +102 -0
  104. package/src/app/pages/api/oauth/device/approve.ts +23 -0
  105. package/src/app/pages/api/oauth/device/index.ts +41 -0
  106. package/src/app/pages/api/oauth/revoke.ts +24 -0
  107. package/src/app/pages/api/oauth/token.ts +31 -0
  108. package/src/app/pages/api/registry/health.ts +4 -3
  109. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
  110. package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
  111. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
  112. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
  113. package/src/app/pages/cli/device.tsx +143 -0
  114. package/src/app/pages/install.tsx +336 -0
  115. package/src/app/pages/login.tsx +117 -0
  116. package/src/app/pages/reset-password.tsx +159 -0
  117. package/src/app/types/next-auth.d.ts +27 -0
  118. package/src/app-builder/build.ts +22 -5
  119. package/src/app-builder/client-config.ts +10 -9
  120. package/src/cli/auth/browser.ts +29 -0
  121. package/src/cli/auth/device.ts +181 -0
  122. package/src/cli/auth/store.ts +137 -0
  123. package/src/cli/scaffold.ts +20 -197
  124. package/src/commands/index.ts +4 -0
  125. package/src/commands/login/index.ts +48 -0
  126. package/src/commands/logout/index.ts +51 -0
  127. package/src/config/entries.ts +175 -0
  128. package/src/config/index.ts +4 -0
  129. package/src/config/runtime.ts +15 -14
  130. package/src/registry/auth/access-tokens.ts +198 -0
  131. package/src/registry/auth/action-tokens.ts +225 -0
  132. package/src/registry/auth/crypto.ts +13 -0
  133. package/src/registry/auth/database.ts +15 -0
  134. package/src/registry/auth/device-authorizations.ts +223 -0
  135. package/src/registry/auth/gravatar.ts +6 -0
  136. package/src/registry/auth/index.ts +89 -0
  137. package/src/registry/auth/installation.ts +117 -0
  138. package/src/registry/auth/password.ts +46 -0
  139. package/src/registry/auth/rate-limits.ts +84 -0
  140. package/src/registry/auth/types.ts +34 -0
  141. package/src/registry/auth/user-record.ts +25 -0
  142. package/src/registry/auth/users.ts +145 -0
  143. package/src/registry/auth/validation.ts +24 -0
  144. package/src/registry/checkout/assets.ts +4 -2
  145. package/src/registry/checkout/index.ts +114 -24
  146. package/src/registry/checkout/tokens.ts +4 -2
  147. package/src/registry/connection.ts +23 -0
  148. package/src/registry/db/driver.ts +5 -4
  149. package/src/registry/db/index.ts +12 -0
  150. package/src/registry/db/schema.ts +174 -1
  151. package/src/registry/publish/assets.ts +1 -1
  152. package/src/registry/publish/errors.ts +3 -3
  153. package/src/registry/publish/index.ts +9 -9
  154. package/src/registry/publish/tokens.ts +1 -1
  155. package/src/types/config.ts +4 -1
  156. package/templates/default/README.md.tpl +18 -0
  157. package/templates/default/gitignore.tpl +1 -0
  158. package/templates/default/handoff.config.js.tpl +0 -1
  159. package/templates/default/handoff.config.ts.tpl +0 -1
  160. package/templates/default/handoff.config.with-examples.js.tpl +0 -1
  161. package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
@@ -2,10 +2,10 @@ import * as p from '@clack/prompts';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
4
  import path from 'path';
5
+ import { isEntryCovered, writeEntries } from '../config/entries';
5
6
  import Handoff from '../index';
6
7
 
7
8
  // Constants
8
- const CONFIG_FILES = ['handoff.config.ts', 'handoff.config.js', 'handoff.config.cjs', 'handoff.config.json'] as const;
9
9
  const COMPONENTS_DIR = 'components';
10
10
  const DEFAULT_GROUPS = [
11
11
  { value: 'Atomic Elements', label: 'Atomic Elements' },
@@ -199,182 +199,6 @@ const countMatchingRegisteredComponents = (
199
199
  return registeredIds.filter((id) => figmaSet.has(id.toLowerCase())).length;
200
200
  };
201
201
 
202
- /**
203
- * Check if components will be auto-loaded by an existing entry
204
- * e.g., if 'components' directory is listed, all subdirectories are auto-loaded
205
- */
206
- const willComponentsAutoLoad = (existingPaths: string[]): boolean => {
207
- // Normalize paths for comparison
208
- const normalizedPaths = existingPaths.map((p) => p.replace(/\/$/, '').toLowerCase());
209
- // If 'components' directory itself is in the list, all components auto-load
210
- return normalizedPaths.includes(COMPONENTS_DIR);
211
- };
212
-
213
- /**
214
- * Format component paths as a properly indented array string for JS files
215
- */
216
- const formatComponentsArray = (paths: string[], indent: string = ' '): string => {
217
- if (paths.length === 0) return '[]';
218
- if (paths.length === 1) return `['${paths[0]}']`;
219
- return `[\n${paths.map((p) => `${indent}'${p}',`).join('\n')}\n${indent.slice(2)}]`;
220
- };
221
-
222
- /**
223
- * Update handoff config file to add component paths
224
- */
225
- const updateConfigFile = async (
226
- handoff: Handoff,
227
- componentNames: string[]
228
- ): Promise<{ success: boolean; isJsConfig: boolean; configPath: string; skipped: boolean }> => {
229
- const configFile = CONFIG_FILES.find((file) =>
230
- fs.existsSync(path.resolve(handoff.workingPath, file))
231
- );
232
-
233
- const newComponentPaths = componentNames.map((name) => `${COMPONENTS_DIR}/${name}`);
234
-
235
- if (!configFile) {
236
- // Create a new handoff.config.json
237
- const newConfig = {
238
- entries: {
239
- components: newComponentPaths,
240
- },
241
- };
242
- const configPath = path.resolve(handoff.workingPath, 'handoff.config.json');
243
- await fs.writeJSON(configPath, newConfig, { spaces: 2 });
244
- return { success: true, isJsConfig: false, configPath, skipped: false };
245
- }
246
-
247
- const configPath = path.resolve(handoff.workingPath, configFile);
248
-
249
- if (configFile.endsWith('.json')) {
250
- const config = await fs.readJSON(configPath);
251
-
252
- // Initialize entries if not present
253
- if (!config.entries) {
254
- config.entries = {};
255
- }
256
- if (!config.entries.components) {
257
- config.entries.components = [];
258
- }
259
-
260
- // Check if components will auto-load
261
- if (willComponentsAutoLoad(config.entries.components)) {
262
- return { success: true, isJsConfig: false, configPath, skipped: true };
263
- }
264
-
265
- // Add new component paths
266
- const existingPaths = new Set(config.entries.components);
267
- for (const componentPath of newComponentPaths) {
268
- if (!existingPaths.has(componentPath)) {
269
- config.entries.components.push(componentPath);
270
- }
271
- }
272
-
273
- await fs.writeJSON(configPath, config, { spaces: 2 });
274
- return { success: true, isJsConfig: false, configPath, skipped: false };
275
- }
276
-
277
- // For code config files, try to update them programmatically
278
- if (configFile.endsWith('.ts') || configFile.endsWith('.js') || configFile.endsWith('.cjs')) {
279
- try {
280
- let content = await fs.readFile(configPath, 'utf8');
281
-
282
- // Check if entries.components already exists
283
- const hasEntriesComponents = /entries\s*:\s*\{[\s\S]*?components\s*:/.test(content);
284
-
285
- if (hasEntriesComponents) {
286
- // Find and update the components array
287
- // Match: components: [...] or components: ["..."]
288
- const componentsArrayRegex = /(components\s*:\s*\[)([^\]]*)(\])/;
289
- const match = content.match(componentsArrayRegex);
290
-
291
- if (match) {
292
- const existingContent = match[2].trim();
293
- const existingPaths = existingContent
294
- .split(',')
295
- .map((s) => s.trim().replace(/['"]/g, ''))
296
- .filter((s) => s.length > 0);
297
-
298
- // Check if components will auto-load
299
- if (willComponentsAutoLoad(existingPaths)) {
300
- return { success: true, isJsConfig: true, configPath, skipped: true };
301
- }
302
-
303
- const existingSet = new Set(existingPaths);
304
- const pathsToAdd = newComponentPaths.filter((p) => !existingSet.has(p));
305
-
306
- if (pathsToAdd.length > 0) {
307
- const allPaths = [...existingPaths, ...pathsToAdd];
308
- // Format with proper indentation (detect existing indentation)
309
- const indentMatch = content.match(/components\s*:\s*\[\s*\n(\s*)/);
310
- const indent = indentMatch ? indentMatch[1] : ' ';
311
- const formattedArray = formatComponentsArray(allPaths, indent);
312
- content = content.replace(componentsArrayRegex, `components: ${formattedArray}`);
313
- await fs.writeFile(configPath, content, 'utf8');
314
- }
315
- return { success: true, isJsConfig: true, configPath, skipped: false };
316
- }
317
- }
318
-
319
- // If entries exists but no components, or no entries at all
320
- // Try to add the entries.components section
321
- const hasEntries = /entries\s*:\s*\{/.test(content);
322
-
323
- if (hasEntries) {
324
- // Add components to existing entries object with proper formatting
325
- const formattedArray = formatComponentsArray(newComponentPaths);
326
- content = content.replace(
327
- /(entries\s*:\s*\{)/,
328
- `$1\n components: ${formattedArray},`
329
- );
330
- await fs.writeFile(configPath, content, 'utf8');
331
- return { success: true, isJsConfig: true, configPath, skipped: false };
332
- }
333
-
334
- // No entries object, try to add it before the closing of exported object
335
- const formattedArray = formatComponentsArray(newComponentPaths);
336
- const entriesBlock = ` entries: {\n components: ${formattedArray},\n },\n`;
337
-
338
- // Try to insert after module.exports = {
339
- if (content.includes('module.exports = {')) {
340
- content = content.replace(
341
- /(module\.exports\s*=\s*\{)/,
342
- `$1\n${entriesBlock}`
343
- );
344
- await fs.writeFile(configPath, content, 'utf8');
345
- return { success: true, isJsConfig: true, configPath, skipped: false };
346
- }
347
-
348
- // Try to insert after export default {
349
- if (/export\s+default\s+\{/.test(content)) {
350
- content = content.replace(
351
- /(export\s+default\s+\{)/,
352
- `$1\n${entriesBlock}`
353
- );
354
- await fs.writeFile(configPath, content, 'utf8');
355
- return { success: true, isJsConfig: true, configPath, skipped: false };
356
- }
357
-
358
- // Try to insert into defineConfig({ ... })
359
- if (/defineConfig\s*\(\s*\{/.test(content)) {
360
- content = content.replace(
361
- /(defineConfig\s*\(\s*\{)/,
362
- `$1\n${entriesBlock}`
363
- );
364
- await fs.writeFile(configPath, content, 'utf8');
365
- return { success: true, isJsConfig: true, configPath, skipped: false };
366
- }
367
-
368
- // Fallback: couldn't parse the JS config
369
- return { success: false, isJsConfig: true, configPath, skipped: false };
370
- } catch {
371
- return { success: false, isJsConfig: true, configPath, skipped: false };
372
- }
373
- }
374
-
375
- return { success: false, isJsConfig: false, configPath, skipped: false };
376
- };
377
-
378
202
  /**
379
203
  * Create component files
380
204
  */
@@ -609,21 +433,6 @@ export const runScaffold = async (handoff: Handoff): Promise<void> => {
609
433
  componentNames.push(config.name);
610
434
  }
611
435
 
612
- // Update config if requested
613
- let configUpdateResult: { success: boolean; isJsConfig: boolean; configPath: string; skipped: boolean } | null = null;
614
- if (updateConfig) {
615
- configUpdateResult = await updateConfigFile(handoff, componentNames);
616
- if (!configUpdateResult.success) {
617
- const relativePath = path.relative(handoff.workingPath, configUpdateResult.configPath);
618
- p.log.warn(
619
- `Could not automatically update ${relativePath}. Please manually add these paths to entries.components:`
620
- );
621
- for (const name of componentNames) {
622
- console.log(chalk.yellow(` 'components/${name}'`));
623
- }
624
- }
625
- }
626
-
627
436
  generationSpinner.stop('Files created successfully');
628
437
 
629
438
  // Display summary
@@ -633,12 +442,26 @@ export const runScaffold = async (handoff: Handoff): Promise<void> => {
633
442
  console.log(chalk.dim(` ${relativePath}`));
634
443
  }
635
444
 
636
- if (updateConfig && configUpdateResult?.success) {
637
- const configFileName = path.basename(configUpdateResult.configPath);
638
- if (configUpdateResult.skipped) {
639
- p.log.info(`Config already includes 'components' directory - new components will auto-load`);
445
+ // Update config if requested. Components already covered by a collection directory auto-load,
446
+ // so we only write the uncovered ones into entries.components.
447
+ if (updateConfig) {
448
+ const componentDirs = componentNames.map((name) => path.resolve(handoff.workingPath, COMPONENTS_DIR, name));
449
+ const uncovered = componentDirs.filter((dir) => !isEntryCovered(handoff, 'components', dir));
450
+
451
+ if (uncovered.length === 0) {
452
+ p.log.info(`Config already covers these components - they'll auto-load`);
640
453
  } else {
641
- p.log.success(`Updated ${configFileName} with component paths`);
454
+ const result = await writeEntries(handoff, 'components', uncovered);
455
+ if (result.status === 'added') {
456
+ const configFileName = result.configPath ? path.basename(result.configPath) : 'handoff.config.json';
457
+ p.log.success(`Updated ${configFileName} with component paths`);
458
+ } else {
459
+ const where = result.configPath ? path.relative(handoff.workingPath, result.configPath) : 'handoff.config';
460
+ p.log.warn(`Could not automatically update ${where}. Please manually add these paths to entries.components:`);
461
+ for (const rel of result.pending) {
462
+ console.log(chalk.yellow(` '${rel}'`));
463
+ }
464
+ }
642
465
  }
643
466
  }
644
467
 
@@ -9,6 +9,8 @@ import ejectPagesCommand from './eject/pages';
9
9
  import ejectThemeCommand from './eject/theme';
10
10
  import fetchCommand from './fetch';
11
11
  import initCommand from './init';
12
+ import loginCommand from './login';
13
+ import logoutCommand from './logout';
12
14
  import makeComponentCommand from './make/component';
13
15
  import makePageCommand from './make/page';
14
16
  import makeTemplateCommand from './make/template';
@@ -29,6 +31,8 @@ export const commands = [
29
31
  ejectThemeCommand,
30
32
  fetchCommand,
31
33
  initCommand,
34
+ loginCommand,
35
+ logoutCommand,
32
36
  makePageCommand,
33
37
  makeComponentCommand,
34
38
  makeTemplateCommand,
@@ -0,0 +1,48 @@
1
+ import { CommandModule } from 'yargs';
2
+ import Handoff from '../../';
3
+ import { loginWithDevice } from '../../cli/auth/device';
4
+ import { cliAuthFilePath } from '../../cli/auth/store';
5
+ import { resolveRegistryConnection } from '../../registry/connection';
6
+ import { Logger } from '../../utils/logger';
7
+ import { SharedArgs } from '../types';
8
+ import { getSharedOptions } from '../utils';
9
+
10
+ export interface LoginArgs extends SharedArgs {
11
+ url?: string;
12
+ browser?: boolean;
13
+ }
14
+
15
+ const command: CommandModule<{}, LoginArgs> = {
16
+ command: 'login',
17
+ describe: 'Sign in to a registry using browser device authorization',
18
+ builder: (yargs) =>
19
+ getSharedOptions(yargs)
20
+ .option('url', {
21
+ type: 'string',
22
+ describe: 'Registry URL, including its base path when configured',
23
+ })
24
+ .option('browser', {
25
+ type: 'boolean',
26
+ default: true,
27
+ describe: 'Open the approval URL in the default browser (disable with --no-browser)',
28
+ }),
29
+ handler: async (args: LoginArgs) => {
30
+ const handoff = new Handoff(args.debug, args.force);
31
+ try {
32
+ const configured = resolveRegistryConnection(handoff.config);
33
+ const remoteUrl = args.url?.trim() || configured.url;
34
+ if (!remoteUrl) {
35
+ throw new Error(`Pass --url <registry-url>, configure runtime.registryConnection.url, or set "${configured.urlEnv}".`);
36
+ }
37
+
38
+ await loginWithDevice(handoff.workingPath, remoteUrl, { openBrowser: args.browser !== false });
39
+ Logger.success(`Logged in. Credentials saved to ${cliAuthFilePath(handoff.workingPath)}.`);
40
+ Logger.info('Publish and checkout commands will use this login for the matching registry URL.');
41
+ } catch (error) {
42
+ Logger.error(error instanceof Error ? error.message : String(error));
43
+ process.exitCode = 1;
44
+ }
45
+ },
46
+ };
47
+
48
+ export default command;
@@ -0,0 +1,51 @@
1
+ import { CommandModule } from 'yargs';
2
+ import Handoff from '../../';
3
+ import { revokeAccessToken } from '../../cli/auth/device';
4
+ import { clearCliAuth, cliAuthFilePath, cliAuthMatchesRegistry, normalizeRegistryUrl, readCliAuth } from '../../cli/auth/store';
5
+ import { Logger } from '../../utils/logger';
6
+ import { SharedArgs } from '../types';
7
+ import { getSharedOptions } from '../utils';
8
+
9
+ export interface LogoutArgs extends SharedArgs {
10
+ url?: string;
11
+ }
12
+
13
+ const command: CommandModule<{}, LogoutArgs> = {
14
+ command: 'logout',
15
+ describe: 'Revoke and remove saved registry credentials',
16
+ builder: (yargs) =>
17
+ getSharedOptions(yargs).option('url', {
18
+ type: 'string',
19
+ describe: 'Revoke credentials only when they belong to this exact registry URL',
20
+ }),
21
+ handler: async (args: LogoutArgs) => {
22
+ const handoff = new Handoff(args.debug, args.force);
23
+ const auth = await readCliAuth(handoff.workingPath);
24
+ if (args.url && auth && !cliAuthMatchesRegistry(auth, args.url)) {
25
+ Logger.error(`The saved login belongs to ${auth.remoteUrl}, not ${normalizeRegistryUrl(args.url)}. No credentials were changed.`);
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+
30
+ if (auth) {
31
+ try {
32
+ await revokeAccessToken(auth);
33
+ } catch (error) {
34
+ Logger.warn(
35
+ `Could not revoke the registry token remotely (${error instanceof Error ? error.message : String(error)}). ` +
36
+ 'It will still be removed from this workspace.'
37
+ );
38
+ }
39
+ }
40
+
41
+ try {
42
+ await clearCliAuth(handoff.workingPath);
43
+ Logger.success(`Logged out. Removed ${cliAuthFilePath(handoff.workingPath)}.`);
44
+ } catch (error) {
45
+ Logger.error(`Could not remove saved credentials: ${error instanceof Error ? error.message : String(error)}`);
46
+ process.exitCode = 1;
47
+ }
48
+ },
49
+ };
50
+
51
+ export default command;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Registering directory-list entries (`entries.components` / `entries.patterns`) in the handoff
3
+ * config. Both scaffolding new component stubs and checking entities out of a registry need a
4
+ * freshly-written entity to be discoverable by the workspace build, so both have to handle every
5
+ * way a path can be declared:
6
+ *
7
+ * - a collection directory (e.g. `"components"`) whose subdirectories are entities: a new sibling
8
+ * is auto-discovered at runtime, so nothing needs writing to the config;
9
+ * - an individual entity directory (e.g. `"components/button"`): the entity stays invisible until
10
+ * its own path is added to the list.
11
+ *
12
+ * {@link isEntryCovered} answers "does this already load?" by reusing the runtime expansion
13
+ * ({@link getComponentsForPath}); {@link writeEntries} appends the paths that don't, across the
14
+ * `.json`, `.ts`, `.js`, and `.cjs` config formats.
15
+ */
16
+
17
+ import fs from 'fs-extra';
18
+ import path from 'path';
19
+ import { Config } from '../types/config';
20
+ import { arePathsEqual } from '../utils/path';
21
+ import { getComponentsForPath } from './runtime';
22
+
23
+ /** The `entries` keys whose values are declared as a list of directory paths. */
24
+ export type EntryKind = 'components' | 'patterns';
25
+
26
+ /** Minimal handoff shape needed here; avoids importing the full Handoff class (circular dep). */
27
+ interface ConfigContext {
28
+ config?: Config;
29
+ workingPath: string;
30
+ }
31
+
32
+ /** Config files in precedence order; the first that exists is the one we mutate. */
33
+ const CONFIG_FILES = ['handoff.config.ts', 'handoff.config.js', 'handoff.config.cjs', 'handoff.config.json'] as const;
34
+
35
+ /** Outcome of {@link writeEntries}: `added` on success, `unsupported` when the config couldn't be edited. */
36
+ export interface WriteEntriesResult {
37
+ status: 'added' | 'unsupported';
38
+ /** The config file written (or that would need editing); null only when none exists and creation failed. */
39
+ configPath: string | null;
40
+ /** Workspace-relative POSIX paths written into the config. */
41
+ added: string[];
42
+ /** Workspace-relative POSIX paths the caller must add manually (`status: 'unsupported'`). */
43
+ pending: string[];
44
+ }
45
+
46
+ /** Workspace-relative, POSIX-separated path used as a config entry value. */
47
+ const toEntryPath = (handoff: ConfigContext, targetDir: string): string =>
48
+ path.relative(handoff.workingPath, targetDir).split(path.sep).join('/');
49
+
50
+ /**
51
+ * True when `targetDir` already loads through an existing `entries[kind]` declaration: listed
52
+ * directly, or sitting under a declared collection directory that runtime discovery expands.
53
+ * Reuses the same expansion the runtime uses, so every declaration style is covered.
54
+ */
55
+ export const isEntryCovered = (handoff: ConfigContext, kind: EntryKind, targetDir: string): boolean => {
56
+ const configured = handoff.config?.entries?.[kind];
57
+ if (!configured?.length) {
58
+ return false;
59
+ }
60
+ return configured
61
+ .flatMap((entry) => getComponentsForPath(path.resolve(handoff.workingPath, entry)))
62
+ .some((dir) => arePathsEqual(dir, targetDir));
63
+ };
64
+
65
+ /** Format an entry array as source, matching the surrounding indentation of a code config. */
66
+ const formatEntryArray = (paths: string[], indent: string): string => {
67
+ if (paths.length === 0) return '[]';
68
+ if (paths.length === 1) return `['${paths[0]}']`;
69
+ return `[\n${paths.map((entry) => `${indent}'${entry}',`).join('\n')}\n${indent.slice(2)}]`;
70
+ };
71
+
72
+ /** Append entry paths to a structured `.json` config (lossless read/modify/write). */
73
+ const addToJsonConfig = async (configPath: string, kind: EntryKind, relPaths: string[]): Promise<boolean> => {
74
+ try {
75
+ const config = await fs.readJSON(configPath);
76
+ config.entries = config.entries ?? {};
77
+ const existing: string[] = Array.isArray(config.entries[kind]) ? config.entries[kind] : [];
78
+ const existingSet = new Set(existing);
79
+ for (const relPath of relPaths) {
80
+ if (!existingSet.has(relPath)) existing.push(relPath);
81
+ }
82
+ config.entries[kind] = existing;
83
+ await fs.writeJSON(configPath, config, { spaces: 2 });
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ };
89
+
90
+ /**
91
+ * Best-effort splice of entry paths into an executable `.ts` / `.js` / `.cjs` config. These are
92
+ * modules, not data, so there's no lossless structured write; if a computed or spread `entries`
93
+ * array can't be edited textually, the caller falls back to printing the paths for the user to add.
94
+ */
95
+ const addToCodeConfig = async (configPath: string, kind: EntryKind, relPaths: string[]): Promise<boolean> => {
96
+ try {
97
+ let content = await fs.readFile(configPath, 'utf8');
98
+ const arrayBlock = formatEntryArray(relPaths, ' ');
99
+
100
+ // 1) An `entries: { ... <kind>: [ ... ] }` array already exists, so merge into it.
101
+ const hasKeyArray = new RegExp(`entries\\s*:\\s*\\{[\\s\\S]*?${kind}\\s*:`).test(content);
102
+ const arrayRegex = new RegExp(`(${kind}\\s*:\\s*\\[)([^\\]]*)(\\])`);
103
+ const match = hasKeyArray ? content.match(arrayRegex) : null;
104
+ if (match) {
105
+ const existing = match[2]
106
+ .split(',')
107
+ .map((s) => s.trim().replace(/['"]/g, ''))
108
+ .filter(Boolean);
109
+ const existingSet = new Set(existing);
110
+ const toAdd = relPaths.filter((relPath) => !existingSet.has(relPath));
111
+ if (toAdd.length > 0) {
112
+ const indentMatch = content.match(new RegExp(`${kind}\\s*:\\s*\\[\\s*\\n(\\s*)`));
113
+ const indent = indentMatch ? indentMatch[1] : ' ';
114
+ content = content.replace(arrayRegex, `${kind}: ${formatEntryArray([...existing, ...toAdd], indent)}`);
115
+ await fs.writeFile(configPath, content, 'utf8');
116
+ }
117
+ return true;
118
+ }
119
+
120
+ // 2) An `entries` object exists but not this key, so add the key.
121
+ if (/entries\s*:\s*\{/.test(content)) {
122
+ content = content.replace(/(entries\s*:\s*\{)/, `$1\n ${kind}: ${arrayBlock},`);
123
+ await fs.writeFile(configPath, content, 'utf8');
124
+ return true;
125
+ }
126
+
127
+ // 3) No `entries` object, so insert one into the exported config object.
128
+ const entriesBlock = ` entries: {\n ${kind}: ${arrayBlock},\n },\n`;
129
+ for (const anchor of [/module\.exports\s*=\s*\{/, /export\s+default\s+\{/, /defineConfig\s*\(\s*\{/]) {
130
+ if (anchor.test(content)) {
131
+ content = content.replace(anchor, (matched) => `${matched}\n${entriesBlock}`);
132
+ await fs.writeFile(configPath, content, 'utf8');
133
+ return true;
134
+ }
135
+ }
136
+
137
+ return false;
138
+ } catch {
139
+ return false;
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Add `targetDirs` to `entries[kind]` in the workspace config so the build discovers them. Callers
145
+ * should first drop already-loading dirs via {@link isEntryCovered}. Paths are stored workspace-
146
+ * relative; a `.json` config is edited losslessly, a code config best-effort. When no config file
147
+ * exists a minimal `handoff.config.json` is created.
148
+ */
149
+ export const writeEntries = async (handoff: ConfigContext, kind: EntryKind, targetDirs: string[]): Promise<WriteEntriesResult> => {
150
+ const relPaths = [...new Set(targetDirs.map((dir) => toEntryPath(handoff, dir)))];
151
+ const configFile = CONFIG_FILES.find((file) => fs.existsSync(path.resolve(handoff.workingPath, file)));
152
+ const configPath = configFile ? path.resolve(handoff.workingPath, configFile) : null;
153
+
154
+ if (relPaths.length === 0) {
155
+ return { status: 'added', configPath, added: [], pending: [] };
156
+ }
157
+
158
+ if (!configPath) {
159
+ const newConfigPath = path.resolve(handoff.workingPath, 'handoff.config.json');
160
+ try {
161
+ await fs.writeJSON(newConfigPath, { entries: { [kind]: relPaths } }, { spaces: 2 });
162
+ return { status: 'added', configPath: newConfigPath, added: relPaths, pending: [] };
163
+ } catch {
164
+ return { status: 'unsupported', configPath: null, added: [], pending: relPaths };
165
+ }
166
+ }
167
+
168
+ const ok = configPath.endsWith('.json')
169
+ ? await addToJsonConfig(configPath, kind, relPaths)
170
+ : await addToCodeConfig(configPath, kind, relPaths);
171
+
172
+ return ok
173
+ ? { status: 'added', configPath, added: relPaths, pending: [] }
174
+ : { status: 'unsupported', configPath, added: [], pending: relPaths };
175
+ };
@@ -13,3 +13,7 @@ export { initRuntimeConfig } from './runtime';
13
13
 
14
14
  // Config validation
15
15
  export { validateConfig } from './validator';
16
+
17
+ // Registering directory-list entries (components/patterns)
18
+ export { isEntryCovered, writeEntries } from './entries';
19
+ export type { EntryKind, WriteEntriesResult } from './entries';
@@ -348,25 +348,26 @@ const collectPageMarkdownPaths = (rootDir: string, relativeParts: string[] = [])
348
348
  };
349
349
 
350
350
  /**
351
- * Returns a list of component directories for a given path.
352
- *
353
- * This function determines whether the provided `searchPath` is:
354
- * 1. A single component directory (contains a config file named after the directory)
355
- * 2. A collection of component directories (subdirectories are components)
356
- *
357
- * A directory is considered a component if it contains a config file matching
358
- * `{dirname}.json`, `{dirname}.js`, or `{dirname}.cjs`.
351
+ * True when `searchPath` is itself a single component directory: it holds a declaration file named
352
+ * after the directory (`{dirname}.handoff.*`, or a legacy `{dirname}.{json,js,cjs}`). This is what
353
+ * separates a declared component from a collection directory.
354
+ */
355
+ export const isComponentDirectory = (searchPath: string): boolean =>
356
+ !!resolveComponentDeclaration(searchPath, path.basename(searchPath));
357
+
358
+ /**
359
+ * Returns the component directories for a given path. If `searchPath` is itself a component
360
+ * directory it's returned as-is; otherwise each subdirectory is treated as a potential component.
359
361
  *
360
362
  * @param searchPath - The absolute path to check for components.
361
- * @returns An array of string paths to component directories.
363
+ * @returns Paths to the component directories.
362
364
  */
363
365
  export const getComponentsForPath = (searchPath: string): string[] => {
364
- const dirName = path.basename(searchPath);
365
- const hasOwnConfig = !!resolveComponentDeclaration(searchPath, dirName);
366
+ if (isComponentDirectory(searchPath)) {
367
+ return [searchPath];
368
+ }
366
369
 
367
- // Check if searchPath itself is a component directory (has a config file named after the directory)
368
- if (hasOwnConfig) {
369
- // This directory is a single component
370
+ if (!fs.existsSync(searchPath)) {
370
371
  return [searchPath];
371
372
  }
372
373