handoff-app 2.0.0-2 → 2.0.0-4
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/README.md +140 -83
- package/dist/app-builder/build.js +21 -4
- package/dist/app-builder/client-config.js +9 -8
- package/dist/cli/auth/browser.d.ts +2 -0
- package/dist/cli/auth/browser.js +33 -0
- package/dist/cli/auth/device.d.ts +11 -0
- package/dist/cli/auth/device.js +155 -0
- package/dist/cli/auth/store.d.ts +29 -0
- package/dist/cli/auth/store.js +146 -0
- package/dist/cli/scaffold.js +20 -154
- package/dist/cli.js +1 -1
- package/dist/commands/index.js +4 -0
- package/dist/commands/login/index.d.ts +8 -0
- package/dist/commands/login/index.js +53 -0
- package/dist/commands/logout/index.d.ts +7 -0
- package/dist/commands/logout/index.js +54 -0
- package/dist/config/entries.d.ts +47 -0
- package/dist/config/entries.js +162 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +5 -1
- package/dist/config/runtime.d.ts +9 -9
- package/dist/config/runtime.js +15 -15
- package/dist/registry/auth/access-tokens.d.ts +46 -0
- package/dist/registry/auth/access-tokens.js +165 -0
- package/dist/registry/auth/action-tokens.d.ts +66 -0
- package/dist/registry/auth/action-tokens.js +178 -0
- package/dist/registry/auth/crypto.d.ts +4 -0
- package/dist/registry/auth/crypto.js +16 -0
- package/dist/registry/auth/database.d.ts +7 -0
- package/dist/registry/auth/database.js +22 -0
- package/dist/registry/auth/device-authorizations.d.ts +51 -0
- package/dist/registry/auth/device-authorizations.js +170 -0
- package/dist/registry/auth/gravatar.d.ts +1 -0
- package/dist/registry/auth/gravatar.js +8 -0
- package/dist/registry/auth/index.d.ts +11 -0
- package/dist/registry/auth/index.js +72 -0
- package/dist/registry/auth/installation.d.ts +42 -0
- package/dist/registry/auth/installation.js +105 -0
- package/dist/registry/auth/password.d.ts +12 -0
- package/dist/registry/auth/password.js +58 -0
- package/dist/registry/auth/rate-limits.d.ts +22 -0
- package/dist/registry/auth/rate-limits.js +75 -0
- package/dist/registry/auth/types.d.ts +26 -0
- package/dist/registry/auth/types.js +9 -0
- package/dist/registry/auth/user-record.d.ts +13 -0
- package/dist/registry/auth/user-record.js +16 -0
- package/dist/registry/auth/users.d.ts +31 -0
- package/dist/registry/auth/users.js +135 -0
- package/dist/registry/auth/validation.d.ts +3 -0
- package/dist/registry/auth/validation.js +28 -0
- package/dist/registry/checkout/assets.js +4 -2
- package/dist/registry/checkout/index.d.ts +1 -1
- package/dist/registry/checkout/index.js +101 -23
- package/dist/registry/checkout/tokens.js +4 -2
- package/dist/registry/connection.d.ts +9 -0
- package/dist/registry/connection.js +29 -1
- package/dist/registry/db/driver.d.ts +5 -4
- package/dist/registry/db/driver.js +5 -4
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +7 -1
- package/dist/registry/db/schema.d.ts +2521 -750
- package/dist/registry/db/schema.js +126 -1
- package/dist/registry/publish/assets.js +1 -1
- package/dist/registry/publish/errors.js +3 -3
- package/dist/registry/publish/index.d.ts +1 -1
- package/dist/registry/publish/index.js +9 -9
- package/dist/registry/publish/tokens.js +1 -1
- package/dist/types/config.d.ts +4 -1
- package/docs/cli.md +4 -0
- package/drizzle/0005_add_registry_auth.sql +95 -0
- package/drizzle/meta/0005_snapshot.json +1926 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/app/components/Auth/AccountLayout.tsx +87 -0
- package/src/app/components/Auth/AuthControls.tsx +99 -0
- package/src/app/components/Auth/AuthShell.tsx +73 -0
- package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
- package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
- package/src/app/components/Auth/api.ts +20 -0
- package/src/app/components/Layout/Header.tsx +2 -0
- package/src/app/components/Navigation/MobileNav.tsx +2 -0
- package/src/app/lib/auth/api.ts +113 -0
- package/src/app/lib/auth/config.ts +175 -0
- package/src/app/lib/auth/email.ts +34 -0
- package/src/app/lib/docs-api/runtime-config.ts +0 -14
- package/src/app/lib/registry-api/auth.ts +29 -46
- package/src/app/lib/registry-api/errors.ts +2 -2
- package/src/app/lib/registry-api/handler.ts +19 -14
- package/src/app/lib/registry-api/token-transfer.ts +1 -1
- package/src/app/lib/registry-api/transfer.ts +4 -4
- package/src/app/next.config.mjs +12 -3
- package/src/app/pages/_app.tsx +10 -4
- package/src/app/pages/account/index.tsx +153 -0
- package/src/app/pages/account/tokens.tsx +230 -0
- package/src/app/pages/account/users.tsx +310 -0
- package/src/app/pages/api/account/index.ts +26 -0
- package/src/app/pages/api/account/tokens/[id].ts +17 -0
- package/src/app/pages/api/account/tokens/index.ts +44 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
- package/src/app/pages/api/admin/users/index.ts +53 -0
- package/src/app/pages/api/auth/[...nextauth].ts +4 -0
- package/src/app/pages/api/auth/request-reset.ts +53 -0
- package/src/app/pages/api/auth/reset-password.ts +29 -0
- package/src/app/pages/api/install.ts +102 -0
- package/src/app/pages/api/oauth/device/approve.ts +23 -0
- package/src/app/pages/api/oauth/device/index.ts +41 -0
- package/src/app/pages/api/oauth/revoke.ts +24 -0
- package/src/app/pages/api/oauth/token.ts +31 -0
- package/src/app/pages/api/registry/health.ts +4 -3
- package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
- package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
- package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
- package/src/app/pages/cli/device.tsx +143 -0
- package/src/app/pages/install.tsx +336 -0
- package/src/app/pages/login.tsx +117 -0
- package/src/app/pages/reset-password.tsx +159 -0
- package/src/app/types/next-auth.d.ts +27 -0
- package/src/app-builder/build.ts +22 -5
- package/src/app-builder/client-config.ts +10 -9
- package/src/cli/auth/browser.ts +29 -0
- package/src/cli/auth/device.ts +181 -0
- package/src/cli/auth/store.ts +137 -0
- package/src/cli/scaffold.ts +20 -197
- package/src/cli.ts +1 -1
- package/src/commands/index.ts +4 -0
- package/src/commands/login/index.ts +48 -0
- package/src/commands/logout/index.ts +51 -0
- package/src/config/entries.ts +175 -0
- package/src/config/index.ts +4 -0
- package/src/config/runtime.ts +15 -14
- package/src/registry/auth/access-tokens.ts +198 -0
- package/src/registry/auth/action-tokens.ts +225 -0
- package/src/registry/auth/crypto.ts +13 -0
- package/src/registry/auth/database.ts +15 -0
- package/src/registry/auth/device-authorizations.ts +223 -0
- package/src/registry/auth/gravatar.ts +6 -0
- package/src/registry/auth/index.ts +89 -0
- package/src/registry/auth/installation.ts +117 -0
- package/src/registry/auth/password.ts +46 -0
- package/src/registry/auth/rate-limits.ts +84 -0
- package/src/registry/auth/types.ts +34 -0
- package/src/registry/auth/user-record.ts +25 -0
- package/src/registry/auth/users.ts +145 -0
- package/src/registry/auth/validation.ts +24 -0
- package/src/registry/checkout/assets.ts +4 -2
- package/src/registry/checkout/index.ts +114 -24
- package/src/registry/checkout/tokens.ts +4 -2
- package/src/registry/connection.ts +23 -0
- package/src/registry/db/driver.ts +5 -4
- package/src/registry/db/index.ts +12 -0
- package/src/registry/db/schema.ts +174 -1
- package/src/registry/publish/assets.ts +1 -1
- package/src/registry/publish/errors.ts +3 -3
- package/src/registry/publish/index.ts +9 -9
- package/src/registry/publish/tokens.ts +1 -1
- package/src/types/config.ts +4 -1
- package/templates/default/README.md.tpl +18 -0
- package/templates/default/gitignore.tpl +1 -0
- package/templates/default/handoff.config.js.tpl +0 -1
- package/templates/default/handoff.config.ts.tpl +0 -1
- package/templates/default/handoff.config.with-examples.js.tpl +0 -1
- package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
package/src/cli/scaffold.ts
CHANGED
|
@@ -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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
|
package/src/cli.ts
CHANGED
package/src/commands/index.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/config/index.ts
CHANGED
|
@@ -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';
|
package/src/config/runtime.ts
CHANGED
|
@@ -348,25 +348,26 @@ const collectPageMarkdownPaths = (rootDir: string, relativeParts: string[] = [])
|
|
|
348
348
|
};
|
|
349
349
|
|
|
350
350
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
363
|
+
* @returns Paths to the component directories.
|
|
362
364
|
*/
|
|
363
365
|
export const getComponentsForPath = (searchPath: string): string[] => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
+
if (isComponentDirectory(searchPath)) {
|
|
367
|
+
return [searchPath];
|
|
368
|
+
}
|
|
366
369
|
|
|
367
|
-
|
|
368
|
-
if (hasOwnConfig) {
|
|
369
|
-
// This directory is a single component
|
|
370
|
+
if (!fs.existsSync(searchPath)) {
|
|
370
371
|
return [searchPath];
|
|
371
372
|
}
|
|
372
373
|
|