@ttctl/core 0.0.0 → 0.1.0-rc.1

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 (195) hide show
  1. package/README.md +49 -9
  2. package/dist/__generated__/gateway.d.ts +4546 -0
  3. package/dist/__generated__/gateway.d.ts.map +1 -0
  4. package/dist/__generated__/gateway.js +9 -0
  5. package/dist/__generated__/gateway.js.map +1 -0
  6. package/dist/__generated__/talent-profile-zod-schemas.d.ts +1187 -0
  7. package/dist/__generated__/talent-profile-zod-schemas.d.ts.map +1 -0
  8. package/dist/__generated__/talent-profile-zod-schemas.js +1136 -0
  9. package/dist/__generated__/talent-profile-zod-schemas.js.map +1 -0
  10. package/dist/__generated__/talent-profile.d.ts +1397 -0
  11. package/dist/__generated__/talent-profile.d.ts.map +1 -0
  12. package/dist/__generated__/talent-profile.js +9 -0
  13. package/dist/__generated__/talent-profile.js.map +1 -0
  14. package/dist/__generated__/zod-schemas.d.ts +2895 -0
  15. package/dist/__generated__/zod-schemas.d.ts.map +1 -0
  16. package/dist/__generated__/zod-schemas.js +3121 -0
  17. package/dist/__generated__/zod-schemas.js.map +1 -0
  18. package/dist/__tests__/fixtures/profile/builders.d.ts +74 -0
  19. package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -0
  20. package/dist/__tests__/fixtures/profile/builders.js +196 -0
  21. package/dist/__tests__/fixtures/profile/builders.js.map +1 -0
  22. package/dist/__tests__/fixtures/profile/data.d.ts +39 -0
  23. package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -0
  24. package/dist/__tests__/fixtures/profile/data.js +230 -0
  25. package/dist/__tests__/fixtures/profile/data.js.map +1 -0
  26. package/dist/__tests__/fixtures/profile/index.d.ts +9 -0
  27. package/dist/__tests__/fixtures/profile/index.d.ts.map +1 -0
  28. package/dist/__tests__/fixtures/profile/index.js +10 -0
  29. package/dist/__tests__/fixtures/profile/index.js.map +1 -0
  30. package/dist/__tests__/fixtures/profile/types.d.ts +53 -0
  31. package/dist/__tests__/fixtures/profile/types.d.ts.map +1 -0
  32. package/dist/__tests__/fixtures/profile/types.js +4 -0
  33. package/dist/__tests__/fixtures/profile/types.js.map +1 -0
  34. package/dist/auth/errors.d.ts +82 -0
  35. package/dist/auth/errors.d.ts.map +1 -0
  36. package/dist/auth/errors.js +68 -0
  37. package/dist/auth/errors.js.map +1 -0
  38. package/dist/auth.d.ts +192 -0
  39. package/dist/auth.d.ts.map +1 -0
  40. package/dist/auth.js +294 -0
  41. package/dist/auth.js.map +1 -0
  42. package/dist/config.d.ts +212 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +349 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/configLock.d.ts +50 -0
  47. package/dist/configLock.d.ts.map +1 -0
  48. package/dist/configLock.js +88 -0
  49. package/dist/configLock.js.map +1 -0
  50. package/dist/configWriter.d.ts +97 -0
  51. package/dist/configWriter.d.ts.map +1 -0
  52. package/dist/configWriter.js +687 -0
  53. package/dist/configWriter.js.map +1 -0
  54. package/dist/index.d.ts +37 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +28 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/kill-switch.d.ts +161 -0
  59. package/dist/kill-switch.d.ts.map +1 -0
  60. package/dist/kill-switch.js +235 -0
  61. package/dist/kill-switch.js.map +1 -0
  62. package/dist/lib/date.d.ts +58 -0
  63. package/dist/lib/date.d.ts.map +1 -0
  64. package/dist/lib/date.js +104 -0
  65. package/dist/lib/date.js.map +1 -0
  66. package/dist/lib/diagnostic-log.d.ts +159 -0
  67. package/dist/lib/diagnostic-log.d.ts.map +1 -0
  68. package/dist/lib/diagnostic-log.js +186 -0
  69. package/dist/lib/diagnostic-log.js.map +1 -0
  70. package/dist/lib/package-version.d.ts +19 -0
  71. package/dist/lib/package-version.d.ts.map +1 -0
  72. package/dist/lib/package-version.js +38 -0
  73. package/dist/lib/package-version.js.map +1 -0
  74. package/dist/lib/redact.d.ts +153 -0
  75. package/dist/lib/redact.d.ts.map +1 -0
  76. package/dist/lib/redact.js +207 -0
  77. package/dist/lib/redact.js.map +1 -0
  78. package/dist/lib/text.d.ts +14 -0
  79. package/dist/lib/text.d.ts.map +1 -0
  80. package/dist/lib/text.js +21 -0
  81. package/dist/lib/text.js.map +1 -0
  82. package/dist/lib/wire-shape.d.ts +131 -0
  83. package/dist/lib/wire-shape.d.ts.map +1 -0
  84. package/dist/lib/wire-shape.js +376 -0
  85. package/dist/lib/wire-shape.js.map +1 -0
  86. package/dist/onepassword.d.ts +29 -0
  87. package/dist/onepassword.d.ts.map +1 -0
  88. package/dist/onepassword.js +112 -0
  89. package/dist/onepassword.js.map +1 -0
  90. package/dist/services/_shared/transport.d.ts +148 -0
  91. package/dist/services/_shared/transport.d.ts.map +1 -0
  92. package/dist/services/_shared/transport.js +102 -0
  93. package/dist/services/_shared/transport.js.map +1 -0
  94. package/dist/services/applications/index.d.ts +210 -0
  95. package/dist/services/applications/index.d.ts.map +1 -0
  96. package/dist/services/applications/index.js +240 -0
  97. package/dist/services/applications/index.js.map +1 -0
  98. package/dist/services/availability/index.d.ts +254 -0
  99. package/dist/services/availability/index.d.ts.map +1 -0
  100. package/dist/services/availability/index.js +310 -0
  101. package/dist/services/availability/index.js.map +1 -0
  102. package/dist/services/contracts/index.d.ts +132 -0
  103. package/dist/services/contracts/index.d.ts.map +1 -0
  104. package/dist/services/contracts/index.js +211 -0
  105. package/dist/services/contracts/index.js.map +1 -0
  106. package/dist/services/engagements/index.d.ts +504 -0
  107. package/dist/services/engagements/index.d.ts.map +1 -0
  108. package/dist/services/engagements/index.js +613 -0
  109. package/dist/services/engagements/index.js.map +1 -0
  110. package/dist/services/jobs/index.d.ts +490 -0
  111. package/dist/services/jobs/index.d.ts.map +1 -0
  112. package/dist/services/jobs/index.js +753 -0
  113. package/dist/services/jobs/index.js.map +1 -0
  114. package/dist/services/payments/index.d.ts +415 -0
  115. package/dist/services/payments/index.d.ts.map +1 -0
  116. package/dist/services/payments/index.js +636 -0
  117. package/dist/services/payments/index.js.map +1 -0
  118. package/dist/services/profile/__tests__/fixtures.d.ts +214 -0
  119. package/dist/services/profile/__tests__/fixtures.d.ts.map +1 -0
  120. package/dist/services/profile/__tests__/fixtures.js +176 -0
  121. package/dist/services/profile/__tests__/fixtures.js.map +1 -0
  122. package/dist/services/profile/basic/index.d.ts +390 -0
  123. package/dist/services/profile/basic/index.d.ts.map +1 -0
  124. package/dist/services/profile/basic/index.js +1007 -0
  125. package/dist/services/profile/basic/index.js.map +1 -0
  126. package/dist/services/profile/certifications/index.d.ts +74 -0
  127. package/dist/services/profile/certifications/index.d.ts.map +1 -0
  128. package/dist/services/profile/certifications/index.js +169 -0
  129. package/dist/services/profile/certifications/index.js.map +1 -0
  130. package/dist/services/profile/education/index.d.ts +73 -0
  131. package/dist/services/profile/education/index.d.ts.map +1 -0
  132. package/dist/services/profile/education/index.js +168 -0
  133. package/dist/services/profile/education/index.js.map +1 -0
  134. package/dist/services/profile/employment/index.d.ts +111 -0
  135. package/dist/services/profile/employment/index.d.ts.map +1 -0
  136. package/dist/services/profile/employment/index.js +202 -0
  137. package/dist/services/profile/employment/index.js.map +1 -0
  138. package/dist/services/profile/external/index.d.ts +219 -0
  139. package/dist/services/profile/external/index.d.ts.map +1 -0
  140. package/dist/services/profile/external/index.js +560 -0
  141. package/dist/services/profile/external/index.js.map +1 -0
  142. package/dist/services/profile/index.d.ts +24 -0
  143. package/dist/services/profile/index.d.ts.map +1 -0
  144. package/dist/services/profile/index.js +26 -0
  145. package/dist/services/profile/index.js.map +1 -0
  146. package/dist/services/profile/industries/index.d.ts +130 -0
  147. package/dist/services/profile/industries/index.d.ts.map +1 -0
  148. package/dist/services/profile/industries/index.js +292 -0
  149. package/dist/services/profile/industries/index.js.map +1 -0
  150. package/dist/services/profile/portfolio/index.d.ts +352 -0
  151. package/dist/services/profile/portfolio/index.d.ts.map +1 -0
  152. package/dist/services/profile/portfolio/index.js +833 -0
  153. package/dist/services/profile/portfolio/index.js.map +1 -0
  154. package/dist/services/profile/resume/index.d.ts +60 -0
  155. package/dist/services/profile/resume/index.d.ts.map +1 -0
  156. package/dist/services/profile/resume/index.js +212 -0
  157. package/dist/services/profile/resume/index.js.map +1 -0
  158. package/dist/services/profile/reviews/index.d.ts +137 -0
  159. package/dist/services/profile/reviews/index.d.ts.map +1 -0
  160. package/dist/services/profile/reviews/index.js +431 -0
  161. package/dist/services/profile/reviews/index.js.map +1 -0
  162. package/dist/services/profile/shared.d.ts +127 -0
  163. package/dist/services/profile/shared.d.ts.map +1 -0
  164. package/dist/services/profile/shared.js +155 -0
  165. package/dist/services/profile/shared.js.map +1 -0
  166. package/dist/services/profile/skills/index.d.ts +212 -0
  167. package/dist/services/profile/skills/index.d.ts.map +1 -0
  168. package/dist/services/profile/skills/index.js +461 -0
  169. package/dist/services/profile/skills/index.js.map +1 -0
  170. package/dist/services/profile/visas/index.d.ts +74 -0
  171. package/dist/services/profile/visas/index.d.ts.map +1 -0
  172. package/dist/services/profile/visas/index.js +306 -0
  173. package/dist/services/profile/visas/index.js.map +1 -0
  174. package/dist/services/timesheet/index.d.ts +326 -0
  175. package/dist/services/timesheet/index.d.ts.map +1 -0
  176. package/dist/services/timesheet/index.js +324 -0
  177. package/dist/services/timesheet/index.js.map +1 -0
  178. package/dist/services/translations.d.ts +79 -0
  179. package/dist/services/translations.d.ts.map +1 -0
  180. package/dist/services/translations.js +136 -0
  181. package/dist/services/translations.js.map +1 -0
  182. package/dist/transport-resilience.d.ts +136 -0
  183. package/dist/transport-resilience.d.ts.map +1 -0
  184. package/dist/transport-resilience.js +247 -0
  185. package/dist/transport-resilience.js.map +1 -0
  186. package/dist/transport.d.ts +408 -0
  187. package/dist/transport.d.ts.map +1 -0
  188. package/dist/transport.js +691 -0
  189. package/dist/transport.js.map +1 -0
  190. package/dist/types.d.ts +41 -0
  191. package/dist/types.d.ts.map +1 -0
  192. package/dist/types.js +18 -0
  193. package/dist/types.js.map +1 -0
  194. package/package.json +40 -12
  195. package/index.js +0 -7
@@ -0,0 +1,833 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Oleksii PELYKH
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename } from "node:path";
5
+ import { AuthRevokedError, TtctlError } from "../../../auth/errors.js";
6
+ import { impersonatedMultipartTransport, impersonatedTransport } from "../../../transport.js";
7
+ import { extractProfileId, isAuthRevokedExtensionCode } from "../shared.js";
8
+ import { list as listSkills } from "../skills/index.js";
9
+ export class PortfolioError extends Error {
10
+ code;
11
+ name = "PortfolioError";
12
+ constructor(code, message, options) {
13
+ super(message, options);
14
+ this.code = code;
15
+ }
16
+ }
17
+ /**
18
+ * Portfolio item kind enumeration. Empirically the server requires this
19
+ * as a non-null field on `CreatePortfolioItemInput.portfolioItem`. The
20
+ * WRITE-side enum uses **lowercase snake_case** values — the
21
+ * SCREAMING_SNAKE forms returned by the read-side `PortfolioItemKindEnum`
22
+ * in `research/graphql/gateway/schema.graphql` are REJECTED on create
23
+ * with `"kind: is not included in the list"` (verified live 2026-05-16
24
+ * during issue #314 investigation, see `.tmp/probe-portfolio-kind.mjs`).
25
+ *
26
+ * Use the {@link PORTFOLIO_ITEM_KIND} constant for autocomplete-friendly
27
+ * references. The {@link PortfolioItemKind} type is derived so adding a
28
+ * member here automatically extends the type.
29
+ */
30
+ export const PORTFOLIO_ITEM_KIND = {
31
+ ACCOMPLISHMENT: "accomplishment",
32
+ BASIC: "basic",
33
+ CLASSIC: "classic",
34
+ CODE_BASE: "code_base",
35
+ OTHER_AMAZING_THINGS: "other_amazing_things",
36
+ };
37
+ /**
38
+ * Map a portfolio fragment node from the wire shape to the typed
39
+ * {@link PortfolioItem}. The wire shape carries many more fields (skills,
40
+ * industries, files, kpis, quotes, details unions) — those are out of
41
+ * scope for the v0 read surface and dropped here. Callers that need the
42
+ * extras can issue a richer query in a follow-up.
43
+ */
44
+ function mapPortfolioNode(node) {
45
+ const id = node["id"];
46
+ const skillsConn = node["skills"];
47
+ const industriesConn = node["industries"];
48
+ const skills = Array.isArray(skillsConn?.nodes)
49
+ ? skillsConn.nodes.flatMap((s) => typeof s.id === "string" && typeof s.name === "string" ? [{ id: s.id, name: s.name }] : [])
50
+ : [];
51
+ const industries = Array.isArray(industriesConn?.nodes)
52
+ ? industriesConn.nodes.flatMap((i) => typeof i.id === "string" && typeof i.name === "string" ? [{ id: i.id, name: i.name }] : [])
53
+ : [];
54
+ const rawKind = node["kind"];
55
+ const kind = typeof rawKind === "string" && Object.values(PORTFOLIO_ITEM_KIND).includes(rawKind)
56
+ ? rawKind
57
+ : null;
58
+ return {
59
+ id: typeof id === "string" ? id : "",
60
+ title: node["title"] ?? null,
61
+ description: node["description"] ?? null,
62
+ link: node["link"] ?? null,
63
+ highlight: Boolean(node["highlight"]),
64
+ coverImage: node["coverImage"] ?? null,
65
+ accomplishment: node["accomplishment"] ?? null,
66
+ publicationPermit: node["publicationPermit"] ?? null,
67
+ clientOrCompanyName: node["clientOrCompanyName"] ?? null,
68
+ websiteUrl: node["websiteUrl"] ?? null,
69
+ toptalRelated: node["toptalRelated"] ?? null,
70
+ showViaToptal: node["showViaToptal"] ?? null,
71
+ kind,
72
+ skills,
73
+ industries,
74
+ };
75
+ }
76
+ /**
77
+ * Common "200 with errors" shape handler. Returns the unwrapped payload
78
+ * (the value of the single root data field) as `unknown`; callers
79
+ * narrow at the call site to their per-operation payload shape. The
80
+ * helper centralizes the auth-revoked / GraphQL-error / null-data paths
81
+ * so each operation's domain code stays focused on its own payload
82
+ * shape.
83
+ */
84
+ function unwrapResponse(res, operationName) {
85
+ if (res.status === 401) {
86
+ throw new AuthRevokedError("Session is invalid or expired.");
87
+ }
88
+ if (res.status < 200 || res.status >= 300) {
89
+ throw new PortfolioError("UNKNOWN", `${operationName} returned HTTP ${res.status.toString()}`);
90
+ }
91
+ const body = res.body;
92
+ if (body && Array.isArray(body.errors) && body.errors.length > 0) {
93
+ const first = body.errors[0];
94
+ if (isAuthRevokedExtensionCode(first?.extensions?.code)) {
95
+ throw new AuthRevokedError("Session is invalid or expired.");
96
+ }
97
+ throw new PortfolioError("GRAPHQL_ERROR", `${operationName} failed: ${first?.message ?? "GraphQL error"}`);
98
+ }
99
+ if (!body?.data) {
100
+ throw new PortfolioError("UNKNOWN", `${operationName} response had no \`data\` field`);
101
+ }
102
+ // The mutation/query operations in this module all use a single root
103
+ // field whose name matches the operation (createPortfolioItem,
104
+ // updatePortfolioItem, getPortfolioItems, profile, …). We extract the
105
+ // single value from `data` here.
106
+ const keys = Object.keys(body.data);
107
+ if (keys.length === 0) {
108
+ throw new PortfolioError("UNKNOWN", `${operationName} response had empty \`data\``);
109
+ }
110
+ const firstKey = keys[0];
111
+ const payload = body.data[firstKey];
112
+ if (payload === null || payload === undefined) {
113
+ throw new PortfolioError("UNKNOWN", `${operationName} response had \`null\` payload`);
114
+ }
115
+ return payload;
116
+ }
117
+ /** Translate `userErrors[]` into a `USER_ERROR` `PortfolioError`. */
118
+ function rejectIfUserErrors(errors, operationName) {
119
+ if (Array.isArray(errors) && errors.length > 0) {
120
+ const first = errors[0];
121
+ const fieldHint = first?.key ? ` (${first.key})` : "";
122
+ throw new PortfolioError("USER_ERROR", `${operationName} rejected${fieldHint}: ${first?.message ?? "unknown error"}`);
123
+ }
124
+ }
125
+ /**
126
+ * Wrap a transport call, narrowing transport-level errors to
127
+ * `PortfolioError("NETWORK_ERROR")` while letting `TtctlError` subclasses
128
+ * (`Cf403Error`, `AuthRevokedError`) bubble up so the CLI/MCP surfaces can
129
+ * apply uniform recovery presentation.
130
+ */
131
+ async function withTransportErrors(operationName, fn) {
132
+ try {
133
+ return await fn();
134
+ }
135
+ catch (err) {
136
+ if (err instanceof TtctlError)
137
+ throw err;
138
+ if (err instanceof PortfolioError)
139
+ throw err;
140
+ throw new PortfolioError("NETWORK_ERROR", `${operationName} request failed: ${err.message}`, {
141
+ cause: err,
142
+ });
143
+ }
144
+ }
145
+ /* -------------------------------------------------------------------------- */
146
+ /* Operations */
147
+ /* -------------------------------------------------------------------------- */
148
+ /**
149
+ * Full-document `getPortfolioItems` query. Sent against `talent-profile`
150
+ * (Cloudflare-protected, requires impersonation). Mirrors
151
+ * `research/graphql/talent_profile/operations/getPortfolioItems.graphql`,
152
+ * trimmed to the v0 read surface (no skills/industries/files/details
153
+ * fragments — those are separate sub-domains).
154
+ */
155
+ /**
156
+ * Shared selection set for `PortfolioItem` nodes used across `list`,
157
+ * `create`, `update`, `remove`, `reorder`, and `highlight` mutation
158
+ * responses. Centralized so the `mapPortfolioNode` mapper always sees a
159
+ * consistent shape regardless of which operation produced the response.
160
+ *
161
+ * Includes `kind`, `skills.nodes`, and `industries.nodes` so the
162
+ * `update` read-modify-write path can preserve non-null required fields
163
+ * the server enforces (`showViaToptal`, `skills`).
164
+ */
165
+ const PORTFOLIO_NODE_SELECTION = `
166
+ id
167
+ title
168
+ description
169
+ link
170
+ highlight
171
+ coverImage
172
+ accomplishment
173
+ publicationPermit
174
+ clientOrCompanyName
175
+ websiteUrl
176
+ toptalRelated
177
+ showViaToptal
178
+ kind
179
+ skills { nodes { id name } }
180
+ industries { nodes { id name } }
181
+ `;
182
+ const GET_PORTFOLIO_ITEMS_QUERY = `query getPortfolioItems($profileId: ID!) {
183
+ profile(id: $profileId) {
184
+ id
185
+ portfolioItems {
186
+ nodes { ${PORTFOLIO_NODE_SELECTION} }
187
+ }
188
+ }
189
+ }`;
190
+ /**
191
+ * Fetch the signed-in user's portfolio items. Takes the user's auth token
192
+ * and returns the typed array; the empty-list case returns `[]` (not
193
+ * `null`).
194
+ */
195
+ export async function list(token) {
196
+ const profileId = await extractProfileId(token);
197
+ // Routed to `talent-profile` (impersonated, Cloudflare-protected): the
198
+ // older `mobile-gateway` schema is missing fields the read surface
199
+ // selects (`toptalRelated` empirically — `Cannot query field
200
+ // "toptalRelated" on type "PortfolioItem"`, captured 2026-05-16). The
201
+ // talent-profile schema is authoritative for all mutations in this
202
+ // service; aligning the query surface keeps a single source of truth.
203
+ const res = await withTransportErrors("getPortfolioItems", async () => impersonatedTransport({
204
+ surface: "talent-profile",
205
+ authToken: token,
206
+ body: {
207
+ operationName: "getPortfolioItems",
208
+ query: GET_PORTFOLIO_ITEMS_QUERY,
209
+ variables: { profileId },
210
+ },
211
+ }));
212
+ const profile = unwrapResponse(res, "getPortfolioItems");
213
+ return profile.portfolioItems.nodes.map(mapPortfolioNode);
214
+ }
215
+ /**
216
+ * Full-document `createPortfolioItem` mutation. Sent against
217
+ * `talent-profile`. Returns the freshly-created list so the caller can
218
+ * surface the new item's id without a second list round-trip.
219
+ *
220
+ * **[INFERRED]** Wrapper key `portfolioItem` per Pattern 2 — see
221
+ * `research/notes/10-mutation-input-patterns.md`. Live-capture this
222
+ * shape if the server rejects the wrapper.
223
+ */
224
+ const CREATE_PORTFOLIO_ITEM_MUTATION = `mutation createPortfolioItem($input: CreatePortfolioItemInput!) {
225
+ createPortfolioItem(input: $input) {
226
+ profile {
227
+ id
228
+ portfolioItems {
229
+ nodes { ${PORTFOLIO_NODE_SELECTION} }
230
+ }
231
+ }
232
+ success
233
+ notice
234
+ errors {
235
+ code
236
+ key
237
+ message
238
+ }
239
+ }
240
+ }`;
241
+ /**
242
+ * Default description used when the caller does not supply one. The
243
+ * server validates a minimum length on `description` (≥200 characters,
244
+ * empirically discovered during issue #314); a short title-derived
245
+ * default would be rejected with `"is too short (minimum is 200
246
+ * characters)"` for typical CLI titles. This placeholder satisfies the
247
+ * minimum while remaining obviously a placeholder a user would refine.
248
+ *
249
+ * Length: 273 characters.
250
+ */
251
+ const DEFAULT_PORTFOLIO_DESCRIPTION = "This portfolio item was created via the ttctl CLI without an explicit description. " +
252
+ "Replace this placeholder via `ttctl profile portfolio update <id> --description ...` " +
253
+ "with details about the project, the work performed, the technologies used, and the outcomes achieved.";
254
+ export async function add(token, input) {
255
+ if (!input.title || input.title.trim() === "") {
256
+ throw new PortfolioError("VALIDATION_ERROR", "Portfolio item requires a non-empty `title`.");
257
+ }
258
+ // Runtime guard: TS narrowing requires `industryIds` to exist as a
259
+ // `string[]`, but JS callers / MCP tool inputs / `as any` casts can
260
+ // bypass that. Server-verified to reject `[]` / `null` / omitted with
261
+ // `code: blank, key: industries` (live probe 2026-05-16,
262
+ // `.tmp/probe-empty-industries.mjs`); we surface the same constraint as
263
+ // VALIDATION_ERROR before reaching the wire.
264
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defense-in-depth for non-TS callers
265
+ if (!input.industryIds || input.industryIds.length === 0) {
266
+ throw new PortfolioError("VALIDATION_ERROR", "Portfolio item requires at least one industry id (`industryIds`). " +
267
+ "Discover catalog IDs via `ttctl profile industries autocomplete <query>`, " +
268
+ "then pass `--industry-id <id>` (repeatable) on the CLI.");
269
+ }
270
+ const profileId = await extractProfileId(token);
271
+ // The server rejects `skills: []` with `"You need to add at least one
272
+ // tag"`. When the caller does not supply skills, fall back to the
273
+ // user's first profile skill — preserves the "minimal CLI invocation
274
+ // just works" experience and aligns the portfolio entry with the
275
+ // user's primary skill domain. Empty-profile case surfaces a clear
276
+ // VALIDATION_ERROR pointing the user at remediation.
277
+ const callerSkills = input.skills;
278
+ let resolvedSkills;
279
+ if (callerSkills !== undefined && callerSkills.length > 0) {
280
+ resolvedSkills = callerSkills;
281
+ }
282
+ else {
283
+ const profileSkills = await listSkills(token, profileId);
284
+ if (profileSkills.length === 0) {
285
+ throw new PortfolioError("VALIDATION_ERROR", "Cannot create portfolio item: profile has no skills. " +
286
+ "Add at least one skill via `ttctl profile skills add <name>`, " +
287
+ "then retry — or pass `skills` explicitly.");
288
+ }
289
+ // The first skill set is typically the talent's primary skill;
290
+ // users who want a different tag association can update the item
291
+ // afterward (or thread `skills` explicitly).
292
+ const first = profileSkills[0];
293
+ if (first === undefined) {
294
+ throw new PortfolioError("UNKNOWN", "Skills list non-empty but indexed lookup returned undefined.");
295
+ }
296
+ resolvedSkills = [{ id: first.skill.id, name: first.skill.name }];
297
+ }
298
+ const portfolioItem = {
299
+ kind: PORTFOLIO_ITEM_KIND.BASIC,
300
+ showViaToptal: true,
301
+ description: DEFAULT_PORTFOLIO_DESCRIPTION,
302
+ publicationPermit: true,
303
+ ...input,
304
+ // `skills` is set AFTER the spread so the resolved default applies
305
+ // even when the caller passed an empty array (the server treats `[]`
306
+ // identically to `null` for this field).
307
+ skills: resolvedSkills,
308
+ };
309
+ const variables = {
310
+ input: { profileId, portfolioItem },
311
+ };
312
+ const res = await withTransportErrors("createPortfolioItem", async () => impersonatedTransport({
313
+ surface: "talent-profile",
314
+ authToken: token,
315
+ body: {
316
+ operationName: "createPortfolioItem",
317
+ query: CREATE_PORTFOLIO_ITEM_MUTATION,
318
+ variables,
319
+ },
320
+ }));
321
+ const payload = unwrapResponse(res, "createPortfolioItem");
322
+ rejectIfUserErrors(payload.errors, "createPortfolioItem");
323
+ if (payload.success === false) {
324
+ throw new PortfolioError("USER_ERROR", `createPortfolioItem reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
325
+ }
326
+ if (!payload.profile) {
327
+ throw new PortfolioError("UNKNOWN", "createPortfolioItem succeeded but response had no profile payload.");
328
+ }
329
+ return payload.profile.portfolioItems.nodes.map(mapPortfolioNode);
330
+ }
331
+ /**
332
+ * Full-document `updatePortfolioItem` mutation.
333
+ *
334
+ * **[INFERRED]** Wrapper key `portfolioItem` per Pattern 1.
335
+ */
336
+ const UPDATE_PORTFOLIO_ITEM_MUTATION = `mutation updatePortfolioItem($input: UpdatePortfolioItemInput!) {
337
+ updatePortfolioItem(input: $input) {
338
+ profile {
339
+ id
340
+ portfolioItems {
341
+ nodes { ${PORTFOLIO_NODE_SELECTION} }
342
+ }
343
+ }
344
+ success
345
+ notice
346
+ errors {
347
+ code
348
+ key
349
+ message
350
+ }
351
+ }
352
+ }`;
353
+ /**
354
+ * Update a portfolio item by id. Conceptually a partial update — callers
355
+ * supply only the fields they want to change — but the server's
356
+ * `updatePortfolioItem` mutation enforces full-replace semantics on
357
+ * `PortfolioItemUpdateInput` non-null fields. Verified live 2026-05-16
358
+ * (probe `.tmp/probe-portfolio-update-shape2.mjs`): the update input
359
+ * shape DIFFERS from create:
360
+ *
361
+ * - GQL-level non-null required: `showViaToptal`, `skills`.
362
+ * - USER_ERROR (`code: blank`) if missing: `description` (≥200 chars),
363
+ * `publicationPermit`, `industries` (i.e. `industryIds`).
364
+ * - **NOT defined** on `PortfolioItemUpdateInput` (rejected at GQL
365
+ * layer): `kind`, `coverImage`. These are write-once on create; to
366
+ * change a cover, the caller goes through `uploadCover()` →
367
+ * separate mutation, not via `update`.
368
+ *
369
+ * To preserve the partial-update UX over the full-replace wire shape,
370
+ * this function does read-modify-write: fetch the current item, merge
371
+ * caller's `changes` on top of the current state, then send the merged
372
+ * input. The mutation response carries the full post-mutation list,
373
+ * which is returned to callers as `PortfolioItem[]`.
374
+ *
375
+ * The function ACCEPTS the broader `PortfolioItemInput` shape (which
376
+ * still types `kind` / `coverImage`) so callers don't need a separate
377
+ * partial type, but it INTENTIONALLY DROPS those fields from the merged
378
+ * payload before sending — keeping them would surface a confusing
379
+ * "Field is not defined on PortfolioItemUpdateInput" GraphQL error.
380
+ */
381
+ export async function update(token, id, changes) {
382
+ if (Object.keys(changes).length === 0) {
383
+ throw new PortfolioError("VALIDATION_ERROR", "Portfolio update requires at least one field.");
384
+ }
385
+ // Read-modify-write: fetch current state of the target item so we can
386
+ // satisfy the server's non-null requirements on update. `list()` is the
387
+ // single source of truth for the current shape — using it keeps the
388
+ // selection-set in sync with `mapPortfolioNode`.
389
+ const current = (await list(token)).find((it) => it.id === id);
390
+ if (!current) {
391
+ throw new PortfolioError("VALIDATION_ERROR", `Portfolio item ${id} not found.`);
392
+ }
393
+ // Build the merged input: current state as base, caller's `changes` as
394
+ // overrides. The wire shape uses `industryIds: string[]` (catalog IDs)
395
+ // whereas the read shape returns `industries: { id, name }[]` — bridge
396
+ // by projecting current.industries to ids. Caller's `industryIds`
397
+ // (when supplied via `changes`) wins over the projected current set.
398
+ //
399
+ // Conditional spread is required by `exactOptionalPropertyTypes: true`:
400
+ // a `T?: string` field cannot be assigned `undefined` (only omitted or
401
+ // a value), so each potentially-null current field becomes a `&& {...}`
402
+ // spread that contributes nothing when the current value is null.
403
+ //
404
+ // `kind` and `coverImage` are intentionally NOT spread from current —
405
+ // `PortfolioItemUpdateInput` does not define them. Caller's `changes`
406
+ // are merged below; we strip these two fields out of the final payload.
407
+ const merged = {
408
+ ...(current.title !== null && { title: current.title }),
409
+ ...(current.description !== null && { description: current.description }),
410
+ ...(current.link !== null && { link: current.link }),
411
+ ...(current.websiteUrl !== null && { websiteUrl: current.websiteUrl }),
412
+ ...(current.accomplishment !== null && { accomplishment: current.accomplishment }),
413
+ ...(current.publicationPermit !== null && { publicationPermit: current.publicationPermit }),
414
+ ...(current.clientOrCompanyName !== null && { clientOrCompanyName: current.clientOrCompanyName }),
415
+ ...(current.toptalRelated !== null && { toptalRelated: current.toptalRelated }),
416
+ ...(current.showViaToptal !== null && { showViaToptal: current.showViaToptal }),
417
+ highlight: current.highlight,
418
+ skills: current.skills,
419
+ industryIds: current.industries.map((i) => i.id),
420
+ ...changes,
421
+ };
422
+ // Strip the two fields rejected by `PortfolioItemUpdateInput` even if
423
+ // a caller passed them in `changes` — the typed `PortfolioItemInput`
424
+ // is shared with `add()` which DOES accept them. Deleting here keeps
425
+ // the call-site UX uniform: callers can pass `kind` / `coverImage`
426
+ // through the same typed dict; we'll quietly drop them on the update
427
+ // path rather than crashing with a server-side GraphQL error.
428
+ delete merged.kind;
429
+ delete merged.coverImage;
430
+ const variables = {
431
+ input: { portfolioItemId: id, portfolioItem: merged },
432
+ };
433
+ const res = await withTransportErrors("updatePortfolioItem", async () => impersonatedTransport({
434
+ surface: "talent-profile",
435
+ authToken: token,
436
+ body: {
437
+ operationName: "updatePortfolioItem",
438
+ query: UPDATE_PORTFOLIO_ITEM_MUTATION,
439
+ variables,
440
+ },
441
+ }));
442
+ const payload = unwrapResponse(res, "updatePortfolioItem");
443
+ rejectIfUserErrors(payload.errors, "updatePortfolioItem");
444
+ if (payload.success === false) {
445
+ throw new PortfolioError("USER_ERROR", `updatePortfolioItem reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
446
+ }
447
+ if (!payload.profile) {
448
+ throw new PortfolioError("UNKNOWN", "updatePortfolioItem succeeded but response had no profile payload.");
449
+ }
450
+ return payload.profile.portfolioItems.nodes.map(mapPortfolioNode);
451
+ }
452
+ /** Full-document `removePortfolioItem` mutation. Pattern 3 (`Remove<Entity>Input`). */
453
+ const REMOVE_PORTFOLIO_ITEM_MUTATION = `mutation removePortfolioItem($input: RemovePortfolioItemInput!) {
454
+ removePortfolioItem(input: $input) {
455
+ profile {
456
+ id
457
+ portfolioItems {
458
+ nodes { ${PORTFOLIO_NODE_SELECTION} }
459
+ }
460
+ }
461
+ success
462
+ notice
463
+ errors {
464
+ code
465
+ key
466
+ message
467
+ }
468
+ }
469
+ }`;
470
+ /** Remove a portfolio item by id. Returns the post-removal list. */
471
+ export async function remove(token, id) {
472
+ const variables = { input: { portfolioItemId: id } };
473
+ const res = await withTransportErrors("removePortfolioItem", async () => impersonatedTransport({
474
+ surface: "talent-profile",
475
+ authToken: token,
476
+ body: {
477
+ operationName: "removePortfolioItem",
478
+ query: REMOVE_PORTFOLIO_ITEM_MUTATION,
479
+ variables,
480
+ },
481
+ }));
482
+ const payload = unwrapResponse(res, "removePortfolioItem");
483
+ rejectIfUserErrors(payload.errors, "removePortfolioItem");
484
+ if (!payload.profile) {
485
+ throw new PortfolioError("UNKNOWN", "removePortfolioItem succeeded but response had no profile payload.");
486
+ }
487
+ return payload.profile.portfolioItems.nodes.map(mapPortfolioNode);
488
+ }
489
+ /**
490
+ * Full-document `changePortfolioItemPosition` mutation. Pattern 5
491
+ * (`Change<Entity>PositionInput { <entity>Id, position }`).
492
+ */
493
+ const CHANGE_PORTFOLIO_ITEM_POSITION_MUTATION = `mutation changePortfolioItemPosition($input: ChangePortfolioItemPositionInput!) {
494
+ changePortfolioItemPosition(input: $input) {
495
+ profile {
496
+ id
497
+ portfolioItems {
498
+ nodes { ${PORTFOLIO_NODE_SELECTION} }
499
+ }
500
+ }
501
+ success
502
+ notice
503
+ errors {
504
+ code
505
+ key
506
+ message
507
+ }
508
+ }
509
+ }`;
510
+ /**
511
+ * Reorder portfolio items by setting `id` to the absolute `position`. The
512
+ * CLI surface translates the more user-friendly `--before <id>` /
513
+ * `--after <id>` flags to this absolute position; the helpers
514
+ * {@link positionBefore} / {@link positionAfter} compute the position
515
+ * given the current list.
516
+ *
517
+ * Wire shape (verified live 2026-05-16, probe
518
+ * `.tmp/probe-portfolio-reorder.mjs`): the position lives INSIDE
519
+ * `portfolioItem: { position: Int! }` — NOT at the top level of
520
+ * `ChangePortfolioItemPositionInput`. The naive `{ portfolioItemId,
521
+ * position }` shape is rejected with "Field is not defined on
522
+ * ChangePortfolioItemPositionInput" (for `position`) and
523
+ * "portfolioItem (Expected value to not be null)".
524
+ */
525
+ export async function reorder(token, id, position) {
526
+ if (!Number.isInteger(position) || position < 0) {
527
+ throw new PortfolioError("VALIDATION_ERROR", "Portfolio reorder position must be a non-negative integer.");
528
+ }
529
+ const variables = { input: { portfolioItemId: id, portfolioItem: { position } } };
530
+ const res = await withTransportErrors("changePortfolioItemPosition", async () => impersonatedTransport({
531
+ surface: "talent-profile",
532
+ authToken: token,
533
+ body: {
534
+ operationName: "changePortfolioItemPosition",
535
+ query: CHANGE_PORTFOLIO_ITEM_POSITION_MUTATION,
536
+ variables,
537
+ },
538
+ }));
539
+ const payload = unwrapResponse(res, "changePortfolioItemPosition");
540
+ rejectIfUserErrors(payload.errors, "changePortfolioItemPosition");
541
+ if (!payload.profile) {
542
+ throw new PortfolioError("UNKNOWN", "changePortfolioItemPosition succeeded but response had no profile payload.");
543
+ }
544
+ return payload.profile.portfolioItems.nodes.map(mapPortfolioNode);
545
+ }
546
+ /**
547
+ * Compute the absolute position (0-based) the moved item should land at,
548
+ * given a target item to be placed BEFORE. Returns `null` if `targetId`
549
+ * is not in the list. The current list is required because the server's
550
+ * `changePortfolioItemPosition` takes an absolute index, not a relative
551
+ * one — the CLI computes the index from a friendlier neighbour-anchored
552
+ * flag.
553
+ *
554
+ * Verified live 2026-05-16: the server interprets `position` against the
555
+ * POST-REMOVAL list (i.e. it first removes the moving item, then inserts
556
+ * at `position`). Valid range is `[0, N-1]` where `N` is the original
557
+ * list length; `position = N` (= the naive "after the last item")
558
+ * returns `USER_ERROR code: invalidPosition` ("Position should be
559
+ * greater or equal to 0 and less than the number of items").
560
+ *
561
+ * When `movingId` is supplied AND points at an item that exists in
562
+ * `items`, that item is filtered out before computing the target's
563
+ * index — this correctly handles the case where the moving item is
564
+ * already in the list and sits before the target (removing it shifts
565
+ * the target left by 1).
566
+ */
567
+ export function positionBefore(items, targetId, movingId) {
568
+ const filtered = movingId !== undefined ? items.filter((it) => it.id !== movingId) : items;
569
+ const idx = filtered.findIndex((it) => it.id === targetId);
570
+ return idx === -1 ? null : idx;
571
+ }
572
+ /**
573
+ * Compute the absolute position the moved item should land at, given a
574
+ * target item to be placed AFTER. Returns `null` if `targetId` is not in
575
+ * the list.
576
+ *
577
+ * Same post-removal semantics as {@link positionBefore} — pass the
578
+ * `movingId` so the helper can filter it out of `items` before
579
+ * computing the target's index. Without `movingId`, the helper assumes
580
+ * the moving item is not present and computes `idx + 1` directly; that
581
+ * value will be one too high for the common "move A `--after` B" CLI
582
+ * flow when both items are in the list and A is before B, and the
583
+ * server will reject it.
584
+ */
585
+ export function positionAfter(items, targetId, movingId) {
586
+ const filtered = movingId !== undefined ? items.filter((it) => it.id !== movingId) : items;
587
+ const idx = filtered.findIndex((it) => it.id === targetId);
588
+ return idx === -1 ? null : idx + 1;
589
+ }
590
+ /**
591
+ * Full-document `highlightPortfolioItem` mutation. Pattern 4
592
+ * (`Highlight<Entity>Input { <entity>Id, highlight }`). The operation
593
+ * source-of-truth is `research/graphql/talent_profile/operations/highlightPortfolioItem.graphql`.
594
+ *
595
+ * Currently TTCtl exposes a single `highlight <id>` toggle that flips the
596
+ * highlight state on; un-highlighting via this surface lands as a
597
+ * follow-up if the use case surfaces.
598
+ */
599
+ const HIGHLIGHT_PORTFOLIO_ITEM_MUTATION = `mutation highlightPortfolioItem($id: ID!, $highlight: Boolean!) {
600
+ highlightPortfolioItem(input: { portfolioItemId: $id, highlight: $highlight }) {
601
+ portfolioItem {
602
+ id
603
+ highlight
604
+ }
605
+ success
606
+ notice
607
+ errors {
608
+ code
609
+ key
610
+ message
611
+ }
612
+ }
613
+ }`;
614
+ /**
615
+ * Set or clear the "highlight" flag on a portfolio item. Returns the
616
+ * minimal `{ id, highlight }` projection from the mutation payload —
617
+ * fetching the full updated item is a separate `list()` round-trip when
618
+ * needed.
619
+ */
620
+ export async function highlight(token, id, flag = true) {
621
+ const res = await withTransportErrors("highlightPortfolioItem", async () => impersonatedTransport({
622
+ surface: "talent-profile",
623
+ authToken: token,
624
+ body: {
625
+ operationName: "highlightPortfolioItem",
626
+ query: HIGHLIGHT_PORTFOLIO_ITEM_MUTATION,
627
+ variables: { id, highlight: flag },
628
+ },
629
+ }));
630
+ const payload = unwrapResponse(res, "highlightPortfolioItem");
631
+ rejectIfUserErrors(payload.errors, "highlightPortfolioItem");
632
+ if (!payload.portfolioItem) {
633
+ throw new PortfolioError("UNKNOWN", "highlightPortfolioItem succeeded but response had no portfolioItem payload.");
634
+ }
635
+ return { id: payload.portfolioItem.id, highlight: payload.portfolioItem.highlight };
636
+ }
637
+ /**
638
+ * Resolve a {@link FileSource} into a {@link MultipartFile} ready for the
639
+ * multipart body. Path-mode reads the file off disk; buffer-mode is a
640
+ * pass-through.
641
+ */
642
+ async function resolveFileSource(source, fieldLabel) {
643
+ if (source.kind === "buffer") {
644
+ return {
645
+ filename: source.filename,
646
+ content: source.content,
647
+ ...(source.contentType !== undefined ? { contentType: source.contentType } : {}),
648
+ };
649
+ }
650
+ let content;
651
+ try {
652
+ content = await readFile(source.path);
653
+ }
654
+ catch (err) {
655
+ const code = err.code;
656
+ const message = err instanceof Error ? err.message : String(err);
657
+ if (code === "ENOENT") {
658
+ throw new PortfolioError("FILE_NOT_FOUND", `${fieldLabel}: file not found: ${source.path}`);
659
+ }
660
+ throw new PortfolioError("FILE_READ_ERROR", `${fieldLabel}: failed to read ${source.path}: ${message}`);
661
+ }
662
+ return { filename: basename(source.path), content };
663
+ }
664
+ /**
665
+ * Full-document `uploadPortfolioCover` mutation. The `file` variable is a
666
+ * GraphQL `Upload` slot — the multipart map binds it to the
667
+ * `variables.file` path.
668
+ *
669
+ * `transformation` is `PortfolioCoverImageTransformationInput!` with four
670
+ * non-null `Int!` fields — `cropX`, `cropY`, `cropW`, `cropH` (verified
671
+ * live 2026-05-16, probe `.tmp/probe-cover-crop.mjs`). Sending `{}` is
672
+ * rejected at the GQL layer ("Expected value to not be null"). All four
673
+ * fields must be integers in PIXEL units. The server is tolerant of
674
+ * oversized crop boxes (e.g. `cropW: 99999`) and will clamp; it does
675
+ * NOT support normalized 0..1 units.
676
+ *
677
+ * The server also enforces a minimum cover-image dimension of
678
+ * **750 × 500 px** (USER_ERROR `code: invalidImage` —
679
+ * "This image must be at least 750 x 500px and under 5MB to be
680
+ * accepted"). Callers are responsible for ensuring the uploaded file
681
+ * meets this bound; this service does not pre-validate dimensions.
682
+ */
683
+ const UPLOAD_PORTFOLIO_COVER_MUTATION = `mutation uploadPortfolioCover(
684
+ $profileId: ID!
685
+ $transformation: PortfolioCoverImageTransformationInput!
686
+ $file: Upload!
687
+ ) {
688
+ uploadPortfolioCoverImage(
689
+ input: { profileId: $profileId, transformation: $transformation, file: $file }
690
+ ) {
691
+ coverImageCacheName
692
+ coverImageUrl
693
+ success
694
+ notice
695
+ errors {
696
+ code
697
+ key
698
+ message
699
+ }
700
+ }
701
+ }`;
702
+ /**
703
+ * Read width/height from a PNG file's IHDR chunk. PNG signature is
704
+ * 8 bytes; the IHDR chunk follows with a 4-byte length, "IHDR" type,
705
+ * then 4-byte width, 4-byte height (big-endian uint32s). Returns
706
+ * `null` for non-PNG inputs or short buffers (caller falls back to a
707
+ * sentinel oversized crop).
708
+ */
709
+ function parsePngDimensions(buffer) {
710
+ if (buffer.length < 24)
711
+ return null;
712
+ const isPng = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 && buffer[4] === 0x0d;
713
+ if (!isPng)
714
+ return null;
715
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
716
+ const width = view.getUint32(16);
717
+ const height = view.getUint32(20);
718
+ if (width <= 0 || height <= 0)
719
+ return null;
720
+ return { width, height };
721
+ }
722
+ /**
723
+ * Upload a cover image for a portfolio item. The cover image is bound to
724
+ * the user's profile rather than a specific portfolio item — the response
725
+ * carries a `coverImageCacheName` the caller passes back into a
726
+ * subsequent `update()` call (or `add()` for new items) via the
727
+ * `coverImage` field.
728
+ *
729
+ * The `transformation` parameter is optional. When omitted, the service
730
+ * resolves a sensible default: PNG sources have their dimensions read
731
+ * from the IHDR header and used as the whole-image crop
732
+ * `(0, 0, width, height)`; non-PNG sources fall back to a large
733
+ * bounding box `(0, 0, 99999, 99999)` (the server clamps oversized
734
+ * crops). Callers who need explicit control pass `transformation`
735
+ * directly.
736
+ *
737
+ * The server enforces a minimum cover-image size of 750x500px — the
738
+ * service does NOT pre-validate this; callers receive a `USER_ERROR`
739
+ * (`code: invalidImage`) when the image is too small.
740
+ */
741
+ export async function uploadCover(token, source, transformation) {
742
+ const profileId = await extractProfileId(token);
743
+ const file = await resolveFileSource(source, "uploadCover");
744
+ const resolvedTransformation = transformation ?? defaultCoverTransformation(file.content);
745
+ const res = await withTransportErrors("uploadPortfolioCover", async () => impersonatedMultipartTransport({
746
+ surface: "talent-profile",
747
+ authToken: token,
748
+ body: {
749
+ operationName: "uploadPortfolioCover",
750
+ query: UPLOAD_PORTFOLIO_COVER_MUTATION,
751
+ variables: {
752
+ profileId,
753
+ transformation: resolvedTransformation,
754
+ file: null,
755
+ },
756
+ },
757
+ files: { "0": file },
758
+ map: { "0": ["variables.file"] },
759
+ }));
760
+ const payload = unwrapResponse(res, "uploadPortfolioCover");
761
+ rejectIfUserErrors(payload.errors, "uploadPortfolioCover");
762
+ if (payload.success === false) {
763
+ throw new PortfolioError("USER_ERROR", `uploadPortfolioCover reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
764
+ }
765
+ return {
766
+ coverImageCacheName: payload.coverImageCacheName ?? null,
767
+ coverImageUrl: payload.coverImageUrl ?? null,
768
+ };
769
+ }
770
+ /**
771
+ * Sentinel oversized crop bounding box used when image dimensions
772
+ * cannot be statically inferred (non-PNG, malformed PNG). The server
773
+ * clamps oversized crops to the actual image extent.
774
+ */
775
+ const OVERSIZED_CROP = { cropX: 0, cropY: 0, cropW: 99999, cropH: 99999 };
776
+ function defaultCoverTransformation(content) {
777
+ const dims = parsePngDimensions(content);
778
+ if (dims) {
779
+ return { cropX: 0, cropY: 0, cropW: dims.width, cropH: dims.height };
780
+ }
781
+ return OVERSIZED_CROP;
782
+ }
783
+ /**
784
+ * Full-document `uploadPortfolioFile` mutation. Variant of `uploadCover`
785
+ * for arbitrary attachment files (not necessarily images). Same
786
+ * multipart-map shape; the response carries a `fileCacheName` /
787
+ * `fileUrl` pair.
788
+ */
789
+ const UPLOAD_PORTFOLIO_FILE_MUTATION = `mutation uploadPortfolioFile($profileId: ID!, $file: Upload!) {
790
+ uploadPortfolioFile(input: { profileId: $profileId, file: $file }) {
791
+ fileCacheName
792
+ fileUrl
793
+ success
794
+ notice
795
+ errors {
796
+ code
797
+ key
798
+ message
799
+ }
800
+ }
801
+ }`;
802
+ /**
803
+ * Upload a file attachment associated with the user's portfolio. Returns
804
+ * the cache name + url; the CLI surface prints both for downstream use.
805
+ */
806
+ export async function uploadFile(token, source) {
807
+ const profileId = await extractProfileId(token);
808
+ const file = await resolveFileSource(source, "uploadFile");
809
+ const res = await withTransportErrors("uploadPortfolioFile", async () => impersonatedMultipartTransport({
810
+ surface: "talent-profile",
811
+ authToken: token,
812
+ body: {
813
+ operationName: "uploadPortfolioFile",
814
+ query: UPLOAD_PORTFOLIO_FILE_MUTATION,
815
+ variables: {
816
+ profileId,
817
+ file: null,
818
+ },
819
+ },
820
+ files: { "0": file },
821
+ map: { "0": ["variables.file"] },
822
+ }));
823
+ const payload = unwrapResponse(res, "uploadPortfolioFile");
824
+ rejectIfUserErrors(payload.errors, "uploadPortfolioFile");
825
+ if (payload.success === false) {
826
+ throw new PortfolioError("USER_ERROR", `uploadPortfolioFile reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
827
+ }
828
+ return {
829
+ fileCacheName: payload.fileCacheName ?? null,
830
+ fileUrl: payload.fileUrl ?? null,
831
+ };
832
+ }
833
+ //# sourceMappingURL=index.js.map