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.
Files changed (163) 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/cli.js +1 -1
  12. package/dist/commands/index.js +4 -0
  13. package/dist/commands/login/index.d.ts +8 -0
  14. package/dist/commands/login/index.js +53 -0
  15. package/dist/commands/logout/index.d.ts +7 -0
  16. package/dist/commands/logout/index.js +54 -0
  17. package/dist/config/entries.d.ts +47 -0
  18. package/dist/config/entries.js +162 -0
  19. package/dist/config/index.d.ts +2 -0
  20. package/dist/config/index.js +5 -1
  21. package/dist/config/runtime.d.ts +9 -9
  22. package/dist/config/runtime.js +15 -15
  23. package/dist/registry/auth/access-tokens.d.ts +46 -0
  24. package/dist/registry/auth/access-tokens.js +165 -0
  25. package/dist/registry/auth/action-tokens.d.ts +66 -0
  26. package/dist/registry/auth/action-tokens.js +178 -0
  27. package/dist/registry/auth/crypto.d.ts +4 -0
  28. package/dist/registry/auth/crypto.js +16 -0
  29. package/dist/registry/auth/database.d.ts +7 -0
  30. package/dist/registry/auth/database.js +22 -0
  31. package/dist/registry/auth/device-authorizations.d.ts +51 -0
  32. package/dist/registry/auth/device-authorizations.js +170 -0
  33. package/dist/registry/auth/gravatar.d.ts +1 -0
  34. package/dist/registry/auth/gravatar.js +8 -0
  35. package/dist/registry/auth/index.d.ts +11 -0
  36. package/dist/registry/auth/index.js +72 -0
  37. package/dist/registry/auth/installation.d.ts +42 -0
  38. package/dist/registry/auth/installation.js +105 -0
  39. package/dist/registry/auth/password.d.ts +12 -0
  40. package/dist/registry/auth/password.js +58 -0
  41. package/dist/registry/auth/rate-limits.d.ts +22 -0
  42. package/dist/registry/auth/rate-limits.js +75 -0
  43. package/dist/registry/auth/types.d.ts +26 -0
  44. package/dist/registry/auth/types.js +9 -0
  45. package/dist/registry/auth/user-record.d.ts +13 -0
  46. package/dist/registry/auth/user-record.js +16 -0
  47. package/dist/registry/auth/users.d.ts +31 -0
  48. package/dist/registry/auth/users.js +135 -0
  49. package/dist/registry/auth/validation.d.ts +3 -0
  50. package/dist/registry/auth/validation.js +28 -0
  51. package/dist/registry/checkout/assets.js +4 -2
  52. package/dist/registry/checkout/index.d.ts +1 -1
  53. package/dist/registry/checkout/index.js +101 -23
  54. package/dist/registry/checkout/tokens.js +4 -2
  55. package/dist/registry/connection.d.ts +9 -0
  56. package/dist/registry/connection.js +29 -1
  57. package/dist/registry/db/driver.d.ts +5 -4
  58. package/dist/registry/db/driver.js +5 -4
  59. package/dist/registry/db/index.d.ts +1 -1
  60. package/dist/registry/db/index.js +7 -1
  61. package/dist/registry/db/schema.d.ts +2521 -750
  62. package/dist/registry/db/schema.js +126 -1
  63. package/dist/registry/publish/assets.js +1 -1
  64. package/dist/registry/publish/errors.js +3 -3
  65. package/dist/registry/publish/index.d.ts +1 -1
  66. package/dist/registry/publish/index.js +9 -9
  67. package/dist/registry/publish/tokens.js +1 -1
  68. package/dist/types/config.d.ts +4 -1
  69. package/docs/cli.md +4 -0
  70. package/drizzle/0005_add_registry_auth.sql +95 -0
  71. package/drizzle/meta/0005_snapshot.json +1926 -0
  72. package/drizzle/meta/_journal.json +8 -1
  73. package/package.json +5 -4
  74. package/src/app/components/Auth/AccountLayout.tsx +87 -0
  75. package/src/app/components/Auth/AuthControls.tsx +99 -0
  76. package/src/app/components/Auth/AuthShell.tsx +73 -0
  77. package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
  78. package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
  79. package/src/app/components/Auth/api.ts +20 -0
  80. package/src/app/components/Layout/Header.tsx +2 -0
  81. package/src/app/components/Navigation/MobileNav.tsx +2 -0
  82. package/src/app/lib/auth/api.ts +113 -0
  83. package/src/app/lib/auth/config.ts +175 -0
  84. package/src/app/lib/auth/email.ts +34 -0
  85. package/src/app/lib/docs-api/runtime-config.ts +0 -14
  86. package/src/app/lib/registry-api/auth.ts +29 -46
  87. package/src/app/lib/registry-api/errors.ts +2 -2
  88. package/src/app/lib/registry-api/handler.ts +19 -14
  89. package/src/app/lib/registry-api/token-transfer.ts +1 -1
  90. package/src/app/lib/registry-api/transfer.ts +4 -4
  91. package/src/app/next.config.mjs +12 -3
  92. package/src/app/pages/_app.tsx +10 -4
  93. package/src/app/pages/account/index.tsx +153 -0
  94. package/src/app/pages/account/tokens.tsx +230 -0
  95. package/src/app/pages/account/users.tsx +310 -0
  96. package/src/app/pages/api/account/index.ts +26 -0
  97. package/src/app/pages/api/account/tokens/[id].ts +17 -0
  98. package/src/app/pages/api/account/tokens/index.ts +44 -0
  99. package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
  100. package/src/app/pages/api/admin/users/index.ts +53 -0
  101. package/src/app/pages/api/auth/[...nextauth].ts +4 -0
  102. package/src/app/pages/api/auth/request-reset.ts +53 -0
  103. package/src/app/pages/api/auth/reset-password.ts +29 -0
  104. package/src/app/pages/api/install.ts +102 -0
  105. package/src/app/pages/api/oauth/device/approve.ts +23 -0
  106. package/src/app/pages/api/oauth/device/index.ts +41 -0
  107. package/src/app/pages/api/oauth/revoke.ts +24 -0
  108. package/src/app/pages/api/oauth/token.ts +31 -0
  109. package/src/app/pages/api/registry/health.ts +4 -3
  110. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
  111. package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
  112. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
  113. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
  114. package/src/app/pages/cli/device.tsx +143 -0
  115. package/src/app/pages/install.tsx +336 -0
  116. package/src/app/pages/login.tsx +117 -0
  117. package/src/app/pages/reset-password.tsx +159 -0
  118. package/src/app/types/next-auth.d.ts +27 -0
  119. package/src/app-builder/build.ts +22 -5
  120. package/src/app-builder/client-config.ts +10 -9
  121. package/src/cli/auth/browser.ts +29 -0
  122. package/src/cli/auth/device.ts +181 -0
  123. package/src/cli/auth/store.ts +137 -0
  124. package/src/cli/scaffold.ts +20 -197
  125. package/src/cli.ts +1 -1
  126. package/src/commands/index.ts +4 -0
  127. package/src/commands/login/index.ts +48 -0
  128. package/src/commands/logout/index.ts +51 -0
  129. package/src/config/entries.ts +175 -0
  130. package/src/config/index.ts +4 -0
  131. package/src/config/runtime.ts +15 -14
  132. package/src/registry/auth/access-tokens.ts +198 -0
  133. package/src/registry/auth/action-tokens.ts +225 -0
  134. package/src/registry/auth/crypto.ts +13 -0
  135. package/src/registry/auth/database.ts +15 -0
  136. package/src/registry/auth/device-authorizations.ts +223 -0
  137. package/src/registry/auth/gravatar.ts +6 -0
  138. package/src/registry/auth/index.ts +89 -0
  139. package/src/registry/auth/installation.ts +117 -0
  140. package/src/registry/auth/password.ts +46 -0
  141. package/src/registry/auth/rate-limits.ts +84 -0
  142. package/src/registry/auth/types.ts +34 -0
  143. package/src/registry/auth/user-record.ts +25 -0
  144. package/src/registry/auth/users.ts +145 -0
  145. package/src/registry/auth/validation.ts +24 -0
  146. package/src/registry/checkout/assets.ts +4 -2
  147. package/src/registry/checkout/index.ts +114 -24
  148. package/src/registry/checkout/tokens.ts +4 -2
  149. package/src/registry/connection.ts +23 -0
  150. package/src/registry/db/driver.ts +5 -4
  151. package/src/registry/db/index.ts +12 -0
  152. package/src/registry/db/schema.ts +174 -1
  153. package/src/registry/publish/assets.ts +1 -1
  154. package/src/registry/publish/errors.ts +3 -3
  155. package/src/registry/publish/index.ts +9 -9
  156. package/src/registry/publish/tokens.ts +1 -1
  157. package/src/types/config.ts +4 -1
  158. package/templates/default/README.md.tpl +18 -0
  159. package/templates/default/gitignore.tpl +1 -0
  160. package/templates/default/handoff.config.js.tpl +0 -1
  161. package/templates/default/handoff.config.ts.tpl +0 -1
  162. package/templates/default/handoff.config.with-examples.js.tpl +0 -1
  163. package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.revokeAccessToken = exports.loginWithDevice = void 0;
13
+ const logger_1 = require("../../utils/logger");
14
+ const browser_1 = require("./browser");
15
+ const store_1 = require("./store");
16
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
17
+ const DEFAULT_DEVICE_EXPIRES_IN_SECONDS = 15 * 60;
18
+ const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60;
19
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
20
+ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
21
+ const responsePayload = (value) => {
22
+ const envelope = value;
23
+ return envelope && typeof envelope === 'object' && envelope.data ? envelope.data : value;
24
+ };
25
+ const responseError = (value) => {
26
+ if (!value || typeof value !== 'object')
27
+ return {};
28
+ const body = value;
29
+ if (typeof body.error === 'string') {
30
+ return { code: body.error, message: body.error_description || body.error };
31
+ }
32
+ if (body.error && typeof body.error === 'object') {
33
+ return { code: body.error.code, message: body.error.message };
34
+ }
35
+ return {};
36
+ };
37
+ const readJson = (response) => __awaiter(void 0, void 0, void 0, function* () {
38
+ const text = yield response.text();
39
+ if (!text)
40
+ return {};
41
+ try {
42
+ return JSON.parse(text);
43
+ }
44
+ catch (_a) {
45
+ return {};
46
+ }
47
+ });
48
+ const shouldOpenBrowser = (options) => {
49
+ if (options.openBrowser === false)
50
+ return false;
51
+ if (process.env.CI === '1' || process.env.CI === 'true')
52
+ return false;
53
+ return process.env.HANDOFF_LOGIN_NO_BROWSER !== '1' && process.env.HANDOFF_LOGIN_NO_BROWSER !== 'true';
54
+ };
55
+ const positiveNumberOr = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
56
+ /** Complete the device grant and persist the one-time plaintext access token for this workspace. */
57
+ const loginWithDevice = (workingPath_1, remoteUrl_1, ...args_1) => __awaiter(void 0, [workingPath_1, remoteUrl_1, ...args_1], void 0, function* (workingPath, remoteUrl, options = {}) {
58
+ const baseUrl = (0, store_1.normalizeRegistryUrl)(remoteUrl);
59
+ let deviceResponse;
60
+ try {
61
+ deviceResponse = yield fetch(`${baseUrl}/api/oauth/device`, {
62
+ method: 'POST',
63
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
64
+ body: '{}',
65
+ });
66
+ }
67
+ catch (error) {
68
+ throw new Error(`Could not reach the registry at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
69
+ }
70
+ const rawDevice = yield readJson(deviceResponse);
71
+ const device = responsePayload(rawDevice);
72
+ if (!deviceResponse.ok) {
73
+ const failure = responseError(rawDevice);
74
+ throw new Error(`Device authorization failed (${deviceResponse.status}): ${failure.message || deviceResponse.statusText}`);
75
+ }
76
+ if (!device.device_code || !device.user_code || !device.verification_uri) {
77
+ throw new Error('The registry returned an invalid device authorization response.');
78
+ }
79
+ const verificationUrl = (0, store_1.assertRegistryOriginUrl)(baseUrl, device.verification_uri_complete ||
80
+ `${device.verification_uri}${device.verification_uri.includes('?') ? '&' : '?'}user_code=${encodeURIComponent(device.user_code)}`);
81
+ logger_1.Logger.log('');
82
+ logger_1.Logger.log('Approve this device in your browser:');
83
+ logger_1.Logger.log(verificationUrl);
84
+ logger_1.Logger.log('');
85
+ logger_1.Logger.log(`Device code: ${device.user_code}`);
86
+ logger_1.Logger.log('');
87
+ if (shouldOpenBrowser(options)) {
88
+ (0, browser_1.tryOpenBrowser)(verificationUrl);
89
+ logger_1.Logger.info('Attempted to open your default browser.');
90
+ }
91
+ const deadline = Date.now() + positiveNumberOr(device.expires_in, DEFAULT_DEVICE_EXPIRES_IN_SECONDS) * 1000;
92
+ let intervalMs = positiveNumberOr(device.interval, DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
93
+ while (Date.now() < deadline) {
94
+ yield sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
95
+ let tokenResponse;
96
+ try {
97
+ tokenResponse = yield fetch(`${baseUrl}/api/oauth/token`, {
98
+ method: 'POST',
99
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({
101
+ grant_type: DEVICE_GRANT,
102
+ device_code: device.device_code,
103
+ }),
104
+ });
105
+ }
106
+ catch (error) {
107
+ throw new Error(`Could not reach the registry at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
108
+ }
109
+ const rawToken = yield readJson(tokenResponse);
110
+ const token = responsePayload(rawToken);
111
+ if (tokenResponse.ok && token.access_token) {
112
+ const expiresIn = positiveNumberOr(token.expires_in, DEFAULT_TOKEN_EXPIRES_IN_SECONDS);
113
+ const auth = {
114
+ remoteUrl: baseUrl,
115
+ accessToken: token.access_token,
116
+ expiresAtMs: Date.now() + expiresIn * 1000,
117
+ };
118
+ yield (0, store_1.writeCliAuth)(workingPath, auth);
119
+ return auth;
120
+ }
121
+ const failure = responseError(token);
122
+ if (failure.code === 'authorization_pending')
123
+ continue;
124
+ if (failure.code === 'slow_down') {
125
+ intervalMs = Math.min(intervalMs + 5000, 60000);
126
+ continue;
127
+ }
128
+ if (failure.code === 'expired_token')
129
+ break;
130
+ throw new Error(`Token request failed: ${failure.message || tokenResponse.statusText || 'unknown error'}`);
131
+ }
132
+ throw new Error('Device authorization expired. Run `handoff-app login` again.');
133
+ });
134
+ exports.loginWithDevice = loginWithDevice;
135
+ /**
136
+ * Best-effort remote revocation. The caller must clear the local credential regardless of failure.
137
+ * The bearer header identifies the current token while the body supports RFC 7009-style handlers.
138
+ */
139
+ const revokeAccessToken = (auth) => __awaiter(void 0, void 0, void 0, function* () {
140
+ const response = yield fetch(`${(0, store_1.normalizeRegistryUrl)(auth.remoteUrl)}/api/oauth/revoke`, {
141
+ method: 'POST',
142
+ headers: {
143
+ Accept: 'application/json',
144
+ Authorization: `Bearer ${auth.accessToken}`,
145
+ 'Content-Type': 'application/json',
146
+ },
147
+ body: JSON.stringify({ token: auth.accessToken }),
148
+ });
149
+ if (!response.ok) {
150
+ const body = yield readJson(response);
151
+ const failure = responseError(body);
152
+ throw new Error(failure.message || `${response.status} ${response.statusText}`);
153
+ }
154
+ });
155
+ exports.revokeAccessToken = revokeAccessToken;
@@ -0,0 +1,29 @@
1
+ /** Credentials issued to this workspace by one registry. */
2
+ export interface CliAuth {
3
+ remoteUrl: string;
4
+ accessToken: string;
5
+ /** Epoch milliseconds after which the token must not be used. */
6
+ expiresAtMs: number;
7
+ }
8
+ /**
9
+ * Canonicalize a registry URL for both HTTP requests and exact credential matching.
10
+ * Base paths are retained; query strings, fragments, credentials, and trailing slashes are not.
11
+ */
12
+ export declare const normalizeRegistryUrl: (value: string) => string;
13
+ /**
14
+ * A verification URL is only trustworthy if it's a web URL on the same origin as the
15
+ * registry the CLI is authenticating against. This stops a tampered device-authorization
16
+ * response from sending the browser to (or injecting a command via) another origin.
17
+ */
18
+ export declare const assertRegistryOriginUrl: (baseUrl: string, candidate: string) => string;
19
+ export declare const cliAuthFilePath: (workingPath: string) => string;
20
+ /** Invalid or partial credential files are treated as unauthenticated. */
21
+ export declare const readCliAuth: (workingPath: string) => Promise<CliAuth | null>;
22
+ /**
23
+ * Atomically replace the credential file. Restrictive permissions are best-effort on platforms
24
+ * whose filesystems do not support POSIX modes.
25
+ */
26
+ export declare const writeCliAuth: (workingPath: string, auth: CliAuth) => Promise<void>;
27
+ export declare const clearCliAuth: (workingPath: string) => Promise<void>;
28
+ export declare const cliAuthMatchesRegistry: (auth: CliAuth | null, remoteUrl: string) => boolean;
29
+ export declare const cliAuthTokenIsValid: (auth: CliAuth | null, skewMs?: number) => boolean;
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.cliAuthTokenIsValid = exports.cliAuthMatchesRegistry = exports.clearCliAuth = exports.writeCliAuth = exports.readCliAuth = exports.cliAuthFilePath = exports.assertRegistryOriginUrl = exports.normalizeRegistryUrl = void 0;
16
+ const fs_extra_1 = __importDefault(require("fs-extra"));
17
+ const path_1 = __importDefault(require("path"));
18
+ const AUTH_DIRECTORY = '.handoff';
19
+ const AUTH_FILE = 'cli-auth.json';
20
+ /**
21
+ * Canonicalize a registry URL for both HTTP requests and exact credential matching.
22
+ * Base paths are retained; query strings, fragments, credentials, and trailing slashes are not.
23
+ */
24
+ const normalizeRegistryUrl = (value) => {
25
+ let parsed;
26
+ try {
27
+ parsed = new URL(value.trim());
28
+ }
29
+ catch (_a) {
30
+ throw new Error(`Invalid registry URL "${value}". Pass an absolute http:// or https:// URL.`);
31
+ }
32
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
33
+ throw new Error(`Invalid registry URL protocol "${parsed.protocol}". Use http:// or https://.`);
34
+ }
35
+ if (parsed.username || parsed.password) {
36
+ throw new Error('Registry URLs must not contain credentials.');
37
+ }
38
+ const pathname = parsed.pathname.replace(/\/+$/, '');
39
+ return `${parsed.origin}${pathname === '/' ? '' : pathname}`;
40
+ };
41
+ exports.normalizeRegistryUrl = normalizeRegistryUrl;
42
+ /**
43
+ * A verification URL is only trustworthy if it's a web URL on the same origin as the
44
+ * registry the CLI is authenticating against. This stops a tampered device-authorization
45
+ * response from sending the browser to (or injecting a command via) another origin.
46
+ */
47
+ const assertRegistryOriginUrl = (baseUrl, candidate) => {
48
+ let url;
49
+ try {
50
+ url = new URL(candidate);
51
+ }
52
+ catch (_a) {
53
+ throw new Error('The registry returned an invalid verification URL.');
54
+ }
55
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
56
+ throw new Error(`The registry returned a verification URL with an unsupported protocol "${url.protocol}".`);
57
+ }
58
+ if (url.origin !== new URL(baseUrl).origin) {
59
+ throw new Error('The registry returned a verification URL for a different origin than the registry.');
60
+ }
61
+ return url.toString();
62
+ };
63
+ exports.assertRegistryOriginUrl = assertRegistryOriginUrl;
64
+ const cliAuthFilePath = (workingPath) => path_1.default.resolve(workingPath, AUTH_DIRECTORY, AUTH_FILE);
65
+ exports.cliAuthFilePath = cliAuthFilePath;
66
+ const isCliAuth = (value) => {
67
+ if (!value || typeof value !== 'object')
68
+ return false;
69
+ const auth = value;
70
+ return (typeof auth.remoteUrl === 'string' &&
71
+ typeof auth.accessToken === 'string' &&
72
+ auth.accessToken.length > 0 &&
73
+ typeof auth.expiresAtMs === 'number' &&
74
+ Number.isFinite(auth.expiresAtMs));
75
+ };
76
+ /** Invalid or partial credential files are treated as unauthenticated. */
77
+ const readCliAuth = (workingPath) => __awaiter(void 0, void 0, void 0, function* () {
78
+ try {
79
+ const value = (yield fs_extra_1.default.readJson((0, exports.cliAuthFilePath)(workingPath)));
80
+ if (!isCliAuth(value))
81
+ return null;
82
+ return Object.assign(Object.assign({}, value), { remoteUrl: (0, exports.normalizeRegistryUrl)(value.remoteUrl) });
83
+ }
84
+ catch (_a) {
85
+ return null;
86
+ }
87
+ });
88
+ exports.readCliAuth = readCliAuth;
89
+ /**
90
+ * Atomically replace the credential file. Restrictive permissions are best-effort on platforms
91
+ * whose filesystems do not support POSIX modes.
92
+ */
93
+ const writeCliAuth = (workingPath, auth) => __awaiter(void 0, void 0, void 0, function* () {
94
+ const filePath = (0, exports.cliAuthFilePath)(workingPath);
95
+ const directory = path_1.default.dirname(filePath);
96
+ const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
97
+ const value = Object.assign(Object.assign({}, auth), { remoteUrl: (0, exports.normalizeRegistryUrl)(auth.remoteUrl) });
98
+ yield fs_extra_1.default.ensureDir(directory, 0o700);
99
+ try {
100
+ yield fs_extra_1.default.chmod(directory, 0o700);
101
+ }
102
+ catch (_a) {
103
+ // Windows and some mounted filesystems do not implement POSIX modes.
104
+ }
105
+ try {
106
+ yield fs_extra_1.default.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
107
+ try {
108
+ yield fs_extra_1.default.chmod(temporaryPath, 0o600);
109
+ }
110
+ catch (_b) {
111
+ // Best effort; see the directory chmod above.
112
+ }
113
+ yield fs_extra_1.default.rename(temporaryPath, filePath);
114
+ try {
115
+ yield fs_extra_1.default.chmod(filePath, 0o600);
116
+ }
117
+ catch (_c) {
118
+ // Best effort.
119
+ }
120
+ }
121
+ catch (error) {
122
+ yield fs_extra_1.default.remove(temporaryPath).catch(() => undefined);
123
+ throw error;
124
+ }
125
+ });
126
+ exports.writeCliAuth = writeCliAuth;
127
+ const clearCliAuth = (workingPath) => __awaiter(void 0, void 0, void 0, function* () {
128
+ const filePath = (0, exports.cliAuthFilePath)(workingPath);
129
+ if (yield fs_extra_1.default.pathExists(filePath)) {
130
+ yield fs_extra_1.default.remove(filePath);
131
+ }
132
+ });
133
+ exports.clearCliAuth = clearCliAuth;
134
+ const cliAuthMatchesRegistry = (auth, remoteUrl) => {
135
+ if (!auth)
136
+ return false;
137
+ try {
138
+ return auth.remoteUrl === (0, exports.normalizeRegistryUrl)(remoteUrl);
139
+ }
140
+ catch (_a) {
141
+ return false;
142
+ }
143
+ };
144
+ exports.cliAuthMatchesRegistry = cliAuthMatchesRegistry;
145
+ const cliAuthTokenIsValid = (auth, skewMs = 30000) => Boolean((auth === null || auth === void 0 ? void 0 : auth.accessToken) && auth.expiresAtMs > Date.now() + skewMs);
146
+ exports.cliAuthTokenIsValid = cliAuthTokenIsValid;
@@ -40,8 +40,8 @@ const p = __importStar(require("@clack/prompts"));
40
40
  const chalk_1 = __importDefault(require("chalk"));
41
41
  const fs_extra_1 = __importDefault(require("fs-extra"));
42
42
  const path_1 = __importDefault(require("path"));
43
+ const entries_1 = require("../config/entries");
43
44
  // Constants
44
- const CONFIG_FILES = ['handoff.config.ts', 'handoff.config.js', 'handoff.config.cjs', 'handoff.config.json'];
45
45
  const COMPONENTS_DIR = 'components';
46
46
  const DEFAULT_GROUPS = [
47
47
  { value: 'Atomic Elements', label: 'Atomic Elements' },
@@ -197,142 +197,6 @@ const countMatchingRegisteredComponents = (figmaComponents, registeredIds) => {
197
197
  const figmaSet = new Set(figmaComponents.map((c) => c.name.toLowerCase()));
198
198
  return registeredIds.filter((id) => figmaSet.has(id.toLowerCase())).length;
199
199
  };
200
- /**
201
- * Check if components will be auto-loaded by an existing entry
202
- * e.g., if 'components' directory is listed, all subdirectories are auto-loaded
203
- */
204
- const willComponentsAutoLoad = (existingPaths) => {
205
- // Normalize paths for comparison
206
- const normalizedPaths = existingPaths.map((p) => p.replace(/\/$/, '').toLowerCase());
207
- // If 'components' directory itself is in the list, all components auto-load
208
- return normalizedPaths.includes(COMPONENTS_DIR);
209
- };
210
- /**
211
- * Format component paths as a properly indented array string for JS files
212
- */
213
- const formatComponentsArray = (paths, indent = ' ') => {
214
- if (paths.length === 0)
215
- return '[]';
216
- if (paths.length === 1)
217
- return `['${paths[0]}']`;
218
- return `[\n${paths.map((p) => `${indent}'${p}',`).join('\n')}\n${indent.slice(2)}]`;
219
- };
220
- /**
221
- * Update handoff config file to add component paths
222
- */
223
- const updateConfigFile = (handoff, componentNames) => __awaiter(void 0, void 0, void 0, function* () {
224
- const configFile = CONFIG_FILES.find((file) => fs_extra_1.default.existsSync(path_1.default.resolve(handoff.workingPath, file)));
225
- const newComponentPaths = componentNames.map((name) => `${COMPONENTS_DIR}/${name}`);
226
- if (!configFile) {
227
- // Create a new handoff.config.json
228
- const newConfig = {
229
- entries: {
230
- components: newComponentPaths,
231
- },
232
- };
233
- const configPath = path_1.default.resolve(handoff.workingPath, 'handoff.config.json');
234
- yield fs_extra_1.default.writeJSON(configPath, newConfig, { spaces: 2 });
235
- return { success: true, isJsConfig: false, configPath, skipped: false };
236
- }
237
- const configPath = path_1.default.resolve(handoff.workingPath, configFile);
238
- if (configFile.endsWith('.json')) {
239
- const config = yield fs_extra_1.default.readJSON(configPath);
240
- // Initialize entries if not present
241
- if (!config.entries) {
242
- config.entries = {};
243
- }
244
- if (!config.entries.components) {
245
- config.entries.components = [];
246
- }
247
- // Check if components will auto-load
248
- if (willComponentsAutoLoad(config.entries.components)) {
249
- return { success: true, isJsConfig: false, configPath, skipped: true };
250
- }
251
- // Add new component paths
252
- const existingPaths = new Set(config.entries.components);
253
- for (const componentPath of newComponentPaths) {
254
- if (!existingPaths.has(componentPath)) {
255
- config.entries.components.push(componentPath);
256
- }
257
- }
258
- yield fs_extra_1.default.writeJSON(configPath, config, { spaces: 2 });
259
- return { success: true, isJsConfig: false, configPath, skipped: false };
260
- }
261
- // For code config files, try to update them programmatically
262
- if (configFile.endsWith('.ts') || configFile.endsWith('.js') || configFile.endsWith('.cjs')) {
263
- try {
264
- let content = yield fs_extra_1.default.readFile(configPath, 'utf8');
265
- // Check if entries.components already exists
266
- const hasEntriesComponents = /entries\s*:\s*\{[\s\S]*?components\s*:/.test(content);
267
- if (hasEntriesComponents) {
268
- // Find and update the components array
269
- // Match: components: [...] or components: ["..."]
270
- const componentsArrayRegex = /(components\s*:\s*\[)([^\]]*)(\])/;
271
- const match = content.match(componentsArrayRegex);
272
- if (match) {
273
- const existingContent = match[2].trim();
274
- const existingPaths = existingContent
275
- .split(',')
276
- .map((s) => s.trim().replace(/['"]/g, ''))
277
- .filter((s) => s.length > 0);
278
- // Check if components will auto-load
279
- if (willComponentsAutoLoad(existingPaths)) {
280
- return { success: true, isJsConfig: true, configPath, skipped: true };
281
- }
282
- const existingSet = new Set(existingPaths);
283
- const pathsToAdd = newComponentPaths.filter((p) => !existingSet.has(p));
284
- if (pathsToAdd.length > 0) {
285
- const allPaths = [...existingPaths, ...pathsToAdd];
286
- // Format with proper indentation (detect existing indentation)
287
- const indentMatch = content.match(/components\s*:\s*\[\s*\n(\s*)/);
288
- const indent = indentMatch ? indentMatch[1] : ' ';
289
- const formattedArray = formatComponentsArray(allPaths, indent);
290
- content = content.replace(componentsArrayRegex, `components: ${formattedArray}`);
291
- yield fs_extra_1.default.writeFile(configPath, content, 'utf8');
292
- }
293
- return { success: true, isJsConfig: true, configPath, skipped: false };
294
- }
295
- }
296
- // If entries exists but no components, or no entries at all
297
- // Try to add the entries.components section
298
- const hasEntries = /entries\s*:\s*\{/.test(content);
299
- if (hasEntries) {
300
- // Add components to existing entries object with proper formatting
301
- const formattedArray = formatComponentsArray(newComponentPaths);
302
- content = content.replace(/(entries\s*:\s*\{)/, `$1\n components: ${formattedArray},`);
303
- yield fs_extra_1.default.writeFile(configPath, content, 'utf8');
304
- return { success: true, isJsConfig: true, configPath, skipped: false };
305
- }
306
- // No entries object, try to add it before the closing of exported object
307
- const formattedArray = formatComponentsArray(newComponentPaths);
308
- const entriesBlock = ` entries: {\n components: ${formattedArray},\n },\n`;
309
- // Try to insert after module.exports = {
310
- if (content.includes('module.exports = {')) {
311
- content = content.replace(/(module\.exports\s*=\s*\{)/, `$1\n${entriesBlock}`);
312
- yield fs_extra_1.default.writeFile(configPath, content, 'utf8');
313
- return { success: true, isJsConfig: true, configPath, skipped: false };
314
- }
315
- // Try to insert after export default {
316
- if (/export\s+default\s+\{/.test(content)) {
317
- content = content.replace(/(export\s+default\s+\{)/, `$1\n${entriesBlock}`);
318
- yield fs_extra_1.default.writeFile(configPath, content, 'utf8');
319
- return { success: true, isJsConfig: true, configPath, skipped: false };
320
- }
321
- // Try to insert into defineConfig({ ... })
322
- if (/defineConfig\s*\(\s*\{/.test(content)) {
323
- content = content.replace(/(defineConfig\s*\(\s*\{)/, `$1\n${entriesBlock}`);
324
- yield fs_extra_1.default.writeFile(configPath, content, 'utf8');
325
- return { success: true, isJsConfig: true, configPath, skipped: false };
326
- }
327
- // Fallback: couldn't parse the JS config
328
- return { success: false, isJsConfig: true, configPath, skipped: false };
329
- }
330
- catch (_a) {
331
- return { success: false, isJsConfig: true, configPath, skipped: false };
332
- }
333
- }
334
- return { success: false, isJsConfig: false, configPath, skipped: false };
335
- });
336
200
  /**
337
201
  * Create component files
338
202
  */
@@ -513,18 +377,6 @@ const runScaffold = (handoff) => __awaiter(void 0, void 0, void 0, function* ()
513
377
  allCreatedFiles.push(...createdFiles);
514
378
  componentNames.push(config.name);
515
379
  }
516
- // Update config if requested
517
- let configUpdateResult = null;
518
- if (updateConfig) {
519
- configUpdateResult = yield updateConfigFile(handoff, componentNames);
520
- if (!configUpdateResult.success) {
521
- const relativePath = path_1.default.relative(handoff.workingPath, configUpdateResult.configPath);
522
- p.log.warn(`Could not automatically update ${relativePath}. Please manually add these paths to entries.components:`);
523
- for (const name of componentNames) {
524
- console.log(chalk_1.default.yellow(` 'components/${name}'`));
525
- }
526
- }
527
- }
528
380
  generationSpinner.stop('Files created successfully');
529
381
  // Display summary
530
382
  p.log.success('Created files:');
@@ -532,13 +384,27 @@ const runScaffold = (handoff) => __awaiter(void 0, void 0, void 0, function* ()
532
384
  const relativePath = path_1.default.relative(handoff.workingPath, file);
533
385
  console.log(chalk_1.default.dim(` ${relativePath}`));
534
386
  }
535
- if (updateConfig && (configUpdateResult === null || configUpdateResult === void 0 ? void 0 : configUpdateResult.success)) {
536
- const configFileName = path_1.default.basename(configUpdateResult.configPath);
537
- if (configUpdateResult.skipped) {
538
- p.log.info(`Config already includes 'components' directory - new components will auto-load`);
387
+ // Update config if requested. Components already covered by a collection directory auto-load,
388
+ // so we only write the uncovered ones into entries.components.
389
+ if (updateConfig) {
390
+ const componentDirs = componentNames.map((name) => path_1.default.resolve(handoff.workingPath, COMPONENTS_DIR, name));
391
+ const uncovered = componentDirs.filter((dir) => !(0, entries_1.isEntryCovered)(handoff, 'components', dir));
392
+ if (uncovered.length === 0) {
393
+ p.log.info(`Config already covers these components - they'll auto-load`);
539
394
  }
540
395
  else {
541
- p.log.success(`Updated ${configFileName} with component paths`);
396
+ const result = yield (0, entries_1.writeEntries)(handoff, 'components', uncovered);
397
+ if (result.status === 'added') {
398
+ const configFileName = result.configPath ? path_1.default.basename(result.configPath) : 'handoff.config.json';
399
+ p.log.success(`Updated ${configFileName} with component paths`);
400
+ }
401
+ else {
402
+ const where = result.configPath ? path_1.default.relative(handoff.workingPath, result.configPath) : 'handoff.config';
403
+ p.log.warn(`Could not automatically update ${where}. Please manually add these paths to entries.components:`);
404
+ for (const rel of result.pending) {
405
+ console.log(chalk_1.default.yellow(` '${rel}'`));
406
+ }
407
+ }
542
408
  }
543
409
  }
544
410
  p.outro(chalk_1.default.green(`Successfully created ${componentConfigs.length} component stub(s)!`));
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ class HandoffCliError extends Error {
19
19
  * Show the help message
20
20
  */
21
21
  const showVersion = () => {
22
- return 'Handoff App - 1.2.2';
22
+ return 'Handoff App - 2.0.0';
23
23
  };
24
24
  /**
25
25
  * Define a CLI error
@@ -15,6 +15,8 @@ const pages_1 = __importDefault(require("./eject/pages"));
15
15
  const theme_1 = __importDefault(require("./eject/theme"));
16
16
  const fetch_1 = __importDefault(require("./fetch"));
17
17
  const init_1 = __importDefault(require("./init"));
18
+ const login_1 = __importDefault(require("./login"));
19
+ const logout_1 = __importDefault(require("./logout"));
18
20
  const component_1 = __importDefault(require("./make/component"));
19
21
  const page_1 = __importDefault(require("./make/page"));
20
22
  const template_1 = __importDefault(require("./make/template"));
@@ -34,6 +36,8 @@ exports.commands = [
34
36
  theme_1.default,
35
37
  fetch_1.default,
36
38
  init_1.default,
39
+ login_1.default,
40
+ logout_1.default,
37
41
  page_1.default,
38
42
  component_1.default,
39
43
  template_1.default,
@@ -0,0 +1,8 @@
1
+ import { CommandModule } from 'yargs';
2
+ import { SharedArgs } from '../types';
3
+ export interface LoginArgs extends SharedArgs {
4
+ url?: string;
5
+ browser?: boolean;
6
+ }
7
+ declare const command: CommandModule<{}, LoginArgs>;
8
+ export default command;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ const __1 = __importDefault(require("../../"));
16
+ const device_1 = require("../../cli/auth/device");
17
+ const store_1 = require("../../cli/auth/store");
18
+ const connection_1 = require("../../registry/connection");
19
+ const logger_1 = require("../../utils/logger");
20
+ const utils_1 = require("../utils");
21
+ const command = {
22
+ command: 'login',
23
+ describe: 'Sign in to a registry using browser device authorization',
24
+ builder: (yargs) => (0, utils_1.getSharedOptions)(yargs)
25
+ .option('url', {
26
+ type: 'string',
27
+ describe: 'Registry URL, including its base path when configured',
28
+ })
29
+ .option('browser', {
30
+ type: 'boolean',
31
+ default: true,
32
+ describe: 'Open the approval URL in the default browser (disable with --no-browser)',
33
+ }),
34
+ handler: (args) => __awaiter(void 0, void 0, void 0, function* () {
35
+ var _a;
36
+ const handoff = new __1.default(args.debug, args.force);
37
+ try {
38
+ const configured = (0, connection_1.resolveRegistryConnection)(handoff.config);
39
+ const remoteUrl = ((_a = args.url) === null || _a === void 0 ? void 0 : _a.trim()) || configured.url;
40
+ if (!remoteUrl) {
41
+ throw new Error(`Pass --url <registry-url>, configure runtime.registryConnection.url, or set "${configured.urlEnv}".`);
42
+ }
43
+ yield (0, device_1.loginWithDevice)(handoff.workingPath, remoteUrl, { openBrowser: args.browser !== false });
44
+ logger_1.Logger.success(`Logged in. Credentials saved to ${(0, store_1.cliAuthFilePath)(handoff.workingPath)}.`);
45
+ logger_1.Logger.info('Publish and checkout commands will use this login for the matching registry URL.');
46
+ }
47
+ catch (error) {
48
+ logger_1.Logger.error(error instanceof Error ? error.message : String(error));
49
+ process.exitCode = 1;
50
+ }
51
+ }),
52
+ };
53
+ exports.default = command;
@@ -0,0 +1,7 @@
1
+ import { CommandModule } from 'yargs';
2
+ import { SharedArgs } from '../types';
3
+ export interface LogoutArgs extends SharedArgs {
4
+ url?: string;
5
+ }
6
+ declare const command: CommandModule<{}, LogoutArgs>;
7
+ export default command;