hacklab 0.22.2 → 26.921.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 (259) hide show
  1. package/README.md +23 -430
  2. package/dist/index.js +132 -23
  3. package/package.json +20 -42
  4. package/LICENSE +0 -21
  5. package/dist/agent-handoff.d.ts +0 -51
  6. package/dist/agent-handoff.d.ts.map +0 -1
  7. package/dist/agent-handoff.js +0 -91
  8. package/dist/agent-handoff.js.map +0 -1
  9. package/dist/api-client.d.ts +0 -18
  10. package/dist/api-client.d.ts.map +0 -1
  11. package/dist/api-client.js +0 -40
  12. package/dist/api-client.js.map +0 -1
  13. package/dist/belt.d.ts +0 -25
  14. package/dist/belt.d.ts.map +0 -1
  15. package/dist/belt.js +0 -113
  16. package/dist/belt.js.map +0 -1
  17. package/dist/card.d.ts +0 -66
  18. package/dist/card.d.ts.map +0 -1
  19. package/dist/card.js +0 -151
  20. package/dist/card.js.map +0 -1
  21. package/dist/cli.d.ts +0 -2
  22. package/dist/cli.d.ts.map +0 -1
  23. package/dist/cli.js +0 -238
  24. package/dist/cli.js.map +0 -1
  25. package/dist/commands/book.d.ts +0 -14
  26. package/dist/commands/book.d.ts.map +0 -1
  27. package/dist/commands/book.js +0 -90
  28. package/dist/commands/book.js.map +0 -1
  29. package/dist/commands/chat.d.ts +0 -36
  30. package/dist/commands/chat.d.ts.map +0 -1
  31. package/dist/commands/chat.js +0 -642
  32. package/dist/commands/chat.js.map +0 -1
  33. package/dist/commands/config.d.ts +0 -2
  34. package/dist/commands/config.d.ts.map +0 -1
  35. package/dist/commands/config.js +0 -84
  36. package/dist/commands/config.js.map +0 -1
  37. package/dist/commands/daemon.d.ts +0 -19
  38. package/dist/commands/daemon.d.ts.map +0 -1
  39. package/dist/commands/daemon.js +0 -72
  40. package/dist/commands/daemon.js.map +0 -1
  41. package/dist/commands/drop.d.ts +0 -12
  42. package/dist/commands/drop.d.ts.map +0 -1
  43. package/dist/commands/drop.js +0 -103
  44. package/dist/commands/drop.js.map +0 -1
  45. package/dist/commands/drops.d.ts +0 -9
  46. package/dist/commands/drops.d.ts.map +0 -1
  47. package/dist/commands/drops.js +0 -96
  48. package/dist/commands/drops.js.map +0 -1
  49. package/dist/commands/essay.d.ts +0 -40
  50. package/dist/commands/essay.d.ts.map +0 -1
  51. package/dist/commands/essay.js +0 -651
  52. package/dist/commands/essay.js.map +0 -1
  53. package/dist/commands/hackathon.d.ts +0 -2
  54. package/dist/commands/hackathon.d.ts.map +0 -1
  55. package/dist/commands/hackathon.js +0 -893
  56. package/dist/commands/hackathon.js.map +0 -1
  57. package/dist/commands/hacker.d.ts +0 -2
  58. package/dist/commands/hacker.d.ts.map +0 -1
  59. package/dist/commands/hacker.js +0 -87
  60. package/dist/commands/hacker.js.map +0 -1
  61. package/dist/commands/jobs.d.ts +0 -18
  62. package/dist/commands/jobs.d.ts.map +0 -1
  63. package/dist/commands/jobs.js +0 -138
  64. package/dist/commands/jobs.js.map +0 -1
  65. package/dist/commands/keys.d.ts +0 -2
  66. package/dist/commands/keys.d.ts.map +0 -1
  67. package/dist/commands/keys.js +0 -138
  68. package/dist/commands/keys.js.map +0 -1
  69. package/dist/commands/login.d.ts +0 -83
  70. package/dist/commands/login.d.ts.map +0 -1
  71. package/dist/commands/login.js +0 -236
  72. package/dist/commands/login.js.map +0 -1
  73. package/dist/commands/logout.d.ts +0 -2
  74. package/dist/commands/logout.d.ts.map +0 -1
  75. package/dist/commands/logout.js +0 -22
  76. package/dist/commands/logout.js.map +0 -1
  77. package/dist/commands/org-jobs.d.ts +0 -50
  78. package/dist/commands/org-jobs.d.ts.map +0 -1
  79. package/dist/commands/org-jobs.js +0 -383
  80. package/dist/commands/org-jobs.js.map +0 -1
  81. package/dist/commands/org.d.ts +0 -67
  82. package/dist/commands/org.d.ts.map +0 -1
  83. package/dist/commands/org.js +0 -1244
  84. package/dist/commands/org.js.map +0 -1
  85. package/dist/commands/ping.d.ts +0 -2
  86. package/dist/commands/ping.d.ts.map +0 -1
  87. package/dist/commands/ping.js +0 -50
  88. package/dist/commands/ping.js.map +0 -1
  89. package/dist/commands/profile.d.ts +0 -72
  90. package/dist/commands/profile.d.ts.map +0 -1
  91. package/dist/commands/profile.js +0 -672
  92. package/dist/commands/profile.js.map +0 -1
  93. package/dist/commands/project.d.ts +0 -2
  94. package/dist/commands/project.d.ts.map +0 -1
  95. package/dist/commands/project.js +0 -583
  96. package/dist/commands/project.js.map +0 -1
  97. package/dist/commands/referral.d.ts +0 -6
  98. package/dist/commands/referral.d.ts.map +0 -1
  99. package/dist/commands/referral.js +0 -93
  100. package/dist/commands/referral.js.map +0 -1
  101. package/dist/commands/rtfm.d.ts +0 -9
  102. package/dist/commands/rtfm.d.ts.map +0 -1
  103. package/dist/commands/rtfm.js +0 -114
  104. package/dist/commands/rtfm.js.map +0 -1
  105. package/dist/commands/scan.d.ts +0 -13
  106. package/dist/commands/scan.d.ts.map +0 -1
  107. package/dist/commands/scan.js +0 -286
  108. package/dist/commands/scan.js.map +0 -1
  109. package/dist/commands/scout.d.ts +0 -18
  110. package/dist/commands/scout.d.ts.map +0 -1
  111. package/dist/commands/scout.js +0 -232
  112. package/dist/commands/scout.js.map +0 -1
  113. package/dist/commands/setup-rail.d.ts +0 -58
  114. package/dist/commands/setup-rail.d.ts.map +0 -1
  115. package/dist/commands/setup-rail.js +0 -135
  116. package/dist/commands/setup-rail.js.map +0 -1
  117. package/dist/commands/setup.d.ts +0 -29
  118. package/dist/commands/setup.d.ts.map +0 -1
  119. package/dist/commands/setup.js +0 -442
  120. package/dist/commands/setup.js.map +0 -1
  121. package/dist/commands/sync.d.ts +0 -9
  122. package/dist/commands/sync.d.ts.map +0 -1
  123. package/dist/commands/sync.js +0 -345
  124. package/dist/commands/sync.js.map +0 -1
  125. package/dist/commands/update.d.ts +0 -2
  126. package/dist/commands/update.d.ts.map +0 -1
  127. package/dist/commands/update.js +0 -92
  128. package/dist/commands/update.js.map +0 -1
  129. package/dist/commands/whoami.d.ts +0 -2
  130. package/dist/commands/whoami.d.ts.map +0 -1
  131. package/dist/commands/whoami.js +0 -41
  132. package/dist/commands/whoami.js.map +0 -1
  133. package/dist/config.d.ts +0 -78
  134. package/dist/config.d.ts.map +0 -1
  135. package/dist/config.js +0 -93
  136. package/dist/config.js.map +0 -1
  137. package/dist/daily-sync.d.ts +0 -150
  138. package/dist/daily-sync.d.ts.map +0 -1
  139. package/dist/daily-sync.js +0 -665
  140. package/dist/daily-sync.js.map +0 -1
  141. package/dist/dossier.d.ts +0 -73
  142. package/dist/dossier.d.ts.map +0 -1
  143. package/dist/dossier.js +0 -141
  144. package/dist/dossier.js.map +0 -1
  145. package/dist/field-normalize.d.ts +0 -4
  146. package/dist/field-normalize.d.ts.map +0 -1
  147. package/dist/field-normalize.js +0 -8
  148. package/dist/field-normalize.js.map +0 -1
  149. package/dist/index.d.ts +0 -3
  150. package/dist/index.d.ts.map +0 -1
  151. package/dist/index.js.map +0 -1
  152. package/dist/job-fields.d.ts +0 -47
  153. package/dist/job-fields.d.ts.map +0 -1
  154. package/dist/job-fields.js +0 -148
  155. package/dist/job-fields.js.map +0 -1
  156. package/dist/machine.d.ts +0 -15
  157. package/dist/machine.d.ts.map +0 -1
  158. package/dist/machine.js +0 -64
  159. package/dist/machine.js.map +0 -1
  160. package/dist/org-fields.d.ts +0 -57
  161. package/dist/org-fields.d.ts.map +0 -1
  162. package/dist/org-fields.js +0 -167
  163. package/dist/org-fields.js.map +0 -1
  164. package/dist/posthog.d.ts +0 -38
  165. package/dist/posthog.d.ts.map +0 -1
  166. package/dist/posthog.js +0 -224
  167. package/dist/posthog.js.map +0 -1
  168. package/dist/project-fields.d.ts +0 -40
  169. package/dist/project-fields.d.ts.map +0 -1
  170. package/dist/project-fields.js +0 -79
  171. package/dist/project-fields.js.map +0 -1
  172. package/dist/prompt-consent.d.ts +0 -73
  173. package/dist/prompt-consent.d.ts.map +0 -1
  174. package/dist/prompt-consent.js +0 -191
  175. package/dist/prompt-consent.js.map +0 -1
  176. package/dist/prompt-stats.d.ts +0 -226
  177. package/dist/prompt-stats.d.ts.map +0 -1
  178. package/dist/prompt-stats.js +0 -561
  179. package/dist/prompt-stats.js.map +0 -1
  180. package/dist/referral.d.ts +0 -14
  181. package/dist/referral.d.ts.map +0 -1
  182. package/dist/referral.js +0 -28
  183. package/dist/referral.js.map +0 -1
  184. package/dist/registry.d.ts +0 -15
  185. package/dist/registry.d.ts.map +0 -1
  186. package/dist/registry.js +0 -180
  187. package/dist/registry.js.map +0 -1
  188. package/dist/resolve-command.d.ts +0 -20
  189. package/dist/resolve-command.d.ts.map +0 -1
  190. package/dist/resolve-command.js +0 -21
  191. package/dist/resolve-command.js.map +0 -1
  192. package/dist/scanners/antigravity.d.ts +0 -41
  193. package/dist/scanners/antigravity.d.ts.map +0 -1
  194. package/dist/scanners/antigravity.js +0 -210
  195. package/dist/scanners/antigravity.js.map +0 -1
  196. package/dist/scanners/github-copilot-telemetry.d.ts +0 -23
  197. package/dist/scanners/github-copilot-telemetry.d.ts.map +0 -1
  198. package/dist/scanners/github-copilot-telemetry.js +0 -220
  199. package/dist/scanners/github-copilot-telemetry.js.map +0 -1
  200. package/dist/scanners/github-copilot.d.ts +0 -68
  201. package/dist/scanners/github-copilot.d.ts.map +0 -1
  202. package/dist/scanners/github-copilot.js +0 -262
  203. package/dist/scanners/github-copilot.js.map +0 -1
  204. package/dist/scanners/incremental.d.ts +0 -282
  205. package/dist/scanners/incremental.d.ts.map +0 -1
  206. package/dist/scanners/incremental.js +0 -788
  207. package/dist/scanners/incremental.js.map +0 -1
  208. package/dist/scanners/index.d.ts +0 -116
  209. package/dist/scanners/index.d.ts.map +0 -1
  210. package/dist/scanners/index.js +0 -726
  211. package/dist/scanners/index.js.map +0 -1
  212. package/dist/scanners/sqlite.d.ts +0 -18
  213. package/dist/scanners/sqlite.d.ts.map +0 -1
  214. package/dist/scanners/sqlite.js +0 -63
  215. package/dist/scanners/sqlite.js.map +0 -1
  216. package/dist/scanners/util.d.ts +0 -112
  217. package/dist/scanners/util.d.ts.map +0 -1
  218. package/dist/scanners/util.js +0 -167
  219. package/dist/scanners/util.js.map +0 -1
  220. package/dist/session.d.ts +0 -106
  221. package/dist/session.d.ts.map +0 -1
  222. package/dist/session.js +0 -191
  223. package/dist/session.js.map +0 -1
  224. package/dist/share-card-colors.d.ts +0 -19
  225. package/dist/share-card-colors.d.ts.map +0 -1
  226. package/dist/share-card-colors.js +0 -36
  227. package/dist/share-card-colors.js.map +0 -1
  228. package/dist/share-card.d.ts +0 -32
  229. package/dist/share-card.d.ts.map +0 -1
  230. package/dist/share-card.js +0 -420
  231. package/dist/share-card.js.map +0 -1
  232. package/dist/share.d.ts +0 -17
  233. package/dist/share.d.ts.map +0 -1
  234. package/dist/share.js +0 -77
  235. package/dist/share.js.map +0 -1
  236. package/dist/sync.d.ts +0 -100
  237. package/dist/sync.d.ts.map +0 -1
  238. package/dist/sync.js +0 -304
  239. package/dist/sync.js.map +0 -1
  240. package/dist/ui.d.ts +0 -33
  241. package/dist/ui.d.ts.map +0 -1
  242. package/dist/ui.js +0 -127
  243. package/dist/ui.js.map +0 -1
  244. package/dist/utils/npmGlobal.d.ts +0 -30
  245. package/dist/utils/npmGlobal.d.ts.map +0 -1
  246. package/dist/utils/npmGlobal.js +0 -69
  247. package/dist/utils/npmGlobal.js.map +0 -1
  248. package/dist/utils/openBrowser.d.ts +0 -14
  249. package/dist/utils/openBrowser.d.ts.map +0 -1
  250. package/dist/utils/openBrowser.js +0 -47
  251. package/dist/utils/openBrowser.js.map +0 -1
  252. package/dist/utils/updateCheck.d.ts +0 -16
  253. package/dist/utils/updateCheck.d.ts.map +0 -1
  254. package/dist/utils/updateCheck.js +0 -111
  255. package/dist/utils/updateCheck.js.map +0 -1
  256. package/dist/utils/waitForEnter.d.ts +0 -12
  257. package/dist/utils/waitForEnter.d.ts.map +0 -1
  258. package/dist/utils/waitForEnter.js +0 -43
  259. package/dist/utils/waitForEnter.js.map +0 -1
@@ -1,1244 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import * as clack from '@clack/prompts';
3
- import { parse as parseYaml } from 'yaml';
4
- import { emitJsonError, requireSession } from '../api-client.js';
5
- import { ensureScheme } from '../field-normalize.js';
6
- import { normalizeOrgFieldValue, ORG_FIELD_NAMES, ORG_FIELDS, parseOrgDocument, resolveTargetOrg, } from '../org-fields.js';
7
- import { resolveCommand } from '../resolve-command.js';
8
- import { resolveAppUrl, unauthorizedHint } from '../session.js';
9
- import { bold, dim, displayWidth, error, hint, info, padEndTo, stripControl, success, } from '../ui.js';
10
- import { orgJobs } from './org-jobs.js';
11
- // `hacklab org` is a small hub with two audiences. Humans: the bare command
12
- // edits (autosave editor), `claim` / `create` run interactive flows, and the
13
- // no-org case dead-ends into a claim/create menu. Agents: `list` / `view` /
14
- // `set` / `apply` / `claim <slug>` / `create --name …` are non-interactive
15
- // and take `--json`, so a profile can go from nothing to fully filled without
16
- // a human at the prompts. Field definitions live in org-fields.ts.
17
- const SUBCOMMANDS = [
18
- 'list',
19
- 'view',
20
- 'set',
21
- 'apply',
22
- 'claim',
23
- 'create',
24
- 'edit',
25
- 'access',
26
- 'jobs',
27
- ];
28
- // `hacklab org access <list|grant|revoke>` — who controls the org. Nested like
29
- // `hackathon team <verb>` because "grant"/"revoke" mean nothing on their own at
30
- // the `org` level. Note `org a` is now ambiguous (access/apply); `org ap` and
31
- // `org ac` still resolve.
32
- const ACCESS_SUBCOMMANDS = ['list', 'grant', 'revoke'];
33
- // The two things a claim on an org can be. `admin` is everything; `recruiter`
34
- // reaches `org jobs` and nothing else. Kept in step with ORG_CLAIM_ROLES on the
35
- // server (packages/db/src/schema/orgs.ts).
36
- const ORG_ROLES = ['admin', 'recruiter'];
37
- // Must match ORG_SLUG_PATTERN on the server (apps/web/lib/org-payload.ts).
38
- const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
39
- // Human hint for why a company is claimable.
40
- export function claimReason(via) {
41
- if (via === 'email')
42
- return 'email domain';
43
- if (via === 'both')
44
- return 'member · email';
45
- return 'member';
46
- }
47
- // Derive a default slug from a name the same way the server's create-org helper
48
- // does — the user can still edit it before submitting.
49
- export function slugify(name) {
50
- const s = name
51
- .trim()
52
- .toLowerCase()
53
- .replace(/[^a-z0-9]+/g, '-')
54
- .replace(/^-+|-+$/g, '')
55
- .slice(0, 64)
56
- .replace(/-+$/, '');
57
- return s || 'org';
58
- }
59
- // Short, human label for the current value (shown as a select-menu hint and in
60
- // `org view` rows).
61
- export function displayValue(spec, value) {
62
- if (value == null || value === '')
63
- return '(empty)';
64
- if (spec.type === 'boolean')
65
- return value ? 'yes' : 'no';
66
- if (spec.type === 'list') {
67
- return Array.isArray(value) ? value.join(', ') || '(empty)' : String(value);
68
- }
69
- const s = String(value);
70
- return s.length > 48 ? `${s.slice(0, 47)}…` : s;
71
- }
72
- async function fetchOrgState(session) {
73
- const res = await fetch(`${resolveAppUrl(session)}/api/cli/org`, {
74
- headers: { Authorization: `Bearer ${session.token}` },
75
- });
76
- if (res.status === 401)
77
- throw new Error(unauthorizedHint(session));
78
- const data = await res.json().catch(() => null);
79
- if (!res.ok) {
80
- throw new Error(data?.error ?? `failed to load organizations (${res.status})`);
81
- }
82
- return {
83
- organizations: (data?.organizations ?? []),
84
- postable: (data?.postable ?? []),
85
- claimable: (data?.claimable ?? []),
86
- };
87
- }
88
- async function patchOrg(session, orgId, fields) {
89
- const res = await fetch(`${resolveAppUrl(session)}/api/cli/org`, {
90
- method: 'PATCH',
91
- headers: {
92
- 'Content-Type': 'application/json',
93
- Authorization: `Bearer ${session.token}`,
94
- },
95
- body: JSON.stringify({ orgId, fields }),
96
- });
97
- if (res.status === 401)
98
- throw new Error(unauthorizedHint(session));
99
- const data = await res.json().catch(() => null);
100
- if (!res.ok) {
101
- throw new Error(data?.error ?? `update failed (${res.status})`);
102
- }
103
- return data;
104
- }
105
- async function postClaim(session, orgId) {
106
- const res = await fetch(`${resolveAppUrl(session)}/api/cli/org/claim`, {
107
- method: 'POST',
108
- headers: {
109
- 'Content-Type': 'application/json',
110
- Authorization: `Bearer ${session.token}`,
111
- },
112
- body: JSON.stringify({ orgId }),
113
- });
114
- if (res.status === 401)
115
- throw new Error(unauthorizedHint(session));
116
- const data = await res.json().catch(() => null);
117
- if (!res.ok) {
118
- throw new Error(data?.error ?? `claim failed (${res.status})`);
119
- }
120
- return data;
121
- }
122
- // Returns ok:true with the new org, or ok:false with the existing org and
123
- // whether the caller may claim it (a 409 the create flow reroutes, not throws).
124
- async function postCreate(session, fields) {
125
- const res = await fetch(`${resolveAppUrl(session)}/api/cli/org/create`, {
126
- method: 'POST',
127
- headers: {
128
- 'Content-Type': 'application/json',
129
- Authorization: `Bearer ${session.token}`,
130
- },
131
- body: JSON.stringify({ fields }),
132
- });
133
- if (res.status === 401)
134
- throw new Error(unauthorizedHint(session));
135
- const data = await res.json().catch(() => null);
136
- if (res.ok)
137
- return { ok: true, org: data };
138
- if (res.status === 409 && data?.existing) {
139
- return {
140
- ok: false,
141
- existing: data.existing,
142
- claimable: Boolean(data.claimable),
143
- };
144
- }
145
- throw new Error(data?.error ?? `create failed (${res.status})`);
146
- }
147
- /**
148
- * Map an access-route refusal to a stable code.
149
- *
150
- * The route answers with a bare `{ error: "<sentence>" }` — no machine code —
151
- * and it reuses 404 for three different refusals. Classifying on the message is
152
- * the only way to tell them apart, and telling them apart is the point: "there
153
- * is no such account" and "that account doesn't control this org" send the
154
- * caller to completely different next steps. Unrecognized text falls back to a
155
- * generic code rather than guessing.
156
- */
157
- export function accessErrorCode(status, message) {
158
- if (status === 401)
159
- return 'unauthorized';
160
- if (status === 403) {
161
- return /only admins/i.test(message) ? 'not_admin' : 'forbidden';
162
- }
163
- if (status === 409)
164
- return 'last_admin';
165
- if (status === 400)
166
- return 'invalid_args';
167
- if (status === 404) {
168
- if (/^no hacklab account/i.test(message))
169
- return 'no_such_account';
170
- if (/does not control/i.test(message))
171
- return 'not_a_claimant';
172
- return 'org_not_found';
173
- }
174
- return 'error';
175
- }
176
- /** What the caller can do about a refusal. Printed to stderr, never stdout. */
177
- export function accessErrorHint(code) {
178
- if (code === 'last_admin') {
179
- return 'make someone else an admin first: `hacklab org access grant <handle> --role admin`';
180
- }
181
- if (code === 'no_such_account') {
182
- return 'handles are hacklab usernames — check one with `hacklab hacker <handle>`';
183
- }
184
- if (code === 'not_a_claimant') {
185
- return 'see who controls it with `hacklab org access list`';
186
- }
187
- if (code === 'not_admin') {
188
- return 'only admins change access — a recruiter can post jobs and stand down, nothing else';
189
- }
190
- if (code === 'forbidden' || code === 'org_not_found') {
191
- return 'you no longer control this company — `hacklab org list` shows the ones you do';
192
- }
193
- return null;
194
- }
195
- /**
196
- * Call the access route. Returns the parsed body, or the classified refusal —
197
- * refusals are values here (not throws) because every caller maps them to a
198
- * code and a hint rather than to one generic message.
199
- */
200
- async function accessRequest(session, method, query, body) {
201
- const url = new URL(`${resolveAppUrl(session)}/api/cli/org/access`);
202
- for (const [key, value] of Object.entries(query)) {
203
- url.searchParams.set(key, value);
204
- }
205
- let res;
206
- try {
207
- res = await fetch(url, {
208
- method,
209
- headers: {
210
- Authorization: `Bearer ${session.token}`,
211
- ...(body ? { 'Content-Type': 'application/json' } : {}),
212
- },
213
- ...(body ? { body: JSON.stringify(body) } : {}),
214
- });
215
- }
216
- catch (err) {
217
- return {
218
- ok: false,
219
- code: 'error',
220
- message: err instanceof Error ? err.message : String(err),
221
- };
222
- }
223
- const data = (await res.json().catch(() => null));
224
- if (res.ok)
225
- return { ok: true, data: data };
226
- const message = res.status === 401
227
- ? unauthorizedHint(session)
228
- : (data?.error ?? `request failed (${res.status})`);
229
- return { ok: false, code: accessErrorCode(res.status, message), message };
230
- }
231
- // ---------------------------------------------------------------------------
232
- // Agent verbs: list / view / set / apply / claim <slug> / create --name.
233
- // Non-interactive, all take --json; one envelope shape with profile/hacker.
234
- function usage() {
235
- error('usage: hacklab org [list|view|set|apply|claim|create|access|jobs]');
236
- info(` hacklab org ${dim('interactive editor (claim/create hub)')}`);
237
- info(` hacklab org ${dim('list [--json]')} orgs you manage + orgs you can claim`);
238
- info(` hacklab org ${dim('view [--org <slug>] [--json]')}`);
239
- info(` hacklab org ${dim('set <field> <value> [--org <slug>] [--clear] [--json]')}`);
240
- info(` hacklab org ${dim('apply <file|-> [--org <slug>] [--json]')}`);
241
- info(` hacklab org ${dim('claim <slug> [--json]')}`);
242
- info(` hacklab org ${dim('create --name <name> [--slug <slug>] [--website <url>] [--description <text>] [--json]')}`);
243
- info(` hacklab org ${dim('access [list] [--org <slug>] [--json]')} who controls it`);
244
- info(` hacklab org ${dim('access grant <handle> [--role admin|recruiter] [--org <slug>] [--json]')}`);
245
- info(` hacklab org ${dim('access revoke <handle> [--org <slug>] [--json]')}`);
246
- info(` hacklab org ${dim('jobs [list|view|post|close]')} its Job Shop listings`);
247
- info(` fields: ${dim(ORG_FIELD_NAMES.join(', '))}`);
248
- process.exit(1);
249
- }
250
- /** Exit with the mode-appropriate error: JSON envelope or human copy. */
251
- function fail(json, code, message) {
252
- if (json)
253
- emitJsonError(code, message);
254
- error(message);
255
- process.exit(1);
256
- }
257
- function printJson(data) {
258
- console.log(JSON.stringify({ schemaVersion: 1, ...data }, null, 2));
259
- }
260
- /** Pull `--flag <value>` out of argv; returns the value and the rest. */
261
- export function extractOption(args, flag) {
262
- const rest = [];
263
- let value;
264
- for (let i = 0; i < args.length; i++) {
265
- if (args[i] === flag && i + 1 < args.length) {
266
- value = args[++i];
267
- continue;
268
- }
269
- rest.push(args[i]);
270
- }
271
- return { value, rest };
272
- }
273
- /** File contents, or stdin when the path is `-`. */
274
- async function readInput(path) {
275
- if (path === '-') {
276
- const chunks = [];
277
- for await (const chunk of process.stdin)
278
- chunks.push(chunk);
279
- return Buffer.concat(chunks).toString('utf8');
280
- }
281
- return readFile(path, 'utf8');
282
- }
283
- async function loadState(session, json) {
284
- try {
285
- return await fetchOrgState(session);
286
- }
287
- catch (err) {
288
- fail(json, 'error', err instanceof Error ? err.message : String(err));
289
- }
290
- }
291
- function renderOrg(org, appUrl) {
292
- const lines = [];
293
- lines.push(` ${bold(org.name)} ${dim(`/o/${org.slug}`)}`);
294
- lines.push('');
295
- const rows = ORG_FIELDS.filter((f) => f.key !== 'name');
296
- const width = Math.max(...rows.map((f) => f.name.length));
297
- for (const f of rows) {
298
- const value = displayValue(f, org[f.key]);
299
- lines.push(` ${dim(f.name.padEnd(width))} ${value === '(empty)' ? dim(value) : value}`);
300
- }
301
- lines.push('');
302
- lines.push(` ${dim(`${appUrl}/o/${org.slug}`)}`);
303
- return lines;
304
- }
305
- /** Slug edits break old links — surface that in both output modes. */
306
- function slugWarnings(before, after) {
307
- return before === after
308
- ? []
309
- : [`old links to /o/${before} will no longer resolve`];
310
- }
311
- async function orgList(args) {
312
- const json = args.includes('--json');
313
- const session = await requireSession(json);
314
- const state = await loadState(session, json);
315
- if (json) {
316
- printJson(state);
317
- return;
318
- }
319
- const appUrl = resolveAppUrl(session);
320
- if (state.organizations.length === 0) {
321
- info("you don't manage a company yet");
322
- }
323
- else {
324
- info('you manage:');
325
- for (const o of state.organizations) {
326
- console.log(` ${bold(o.name)} ${dim(`${appUrl}/o/${o.slug}`)}`);
327
- }
328
- }
329
- // Recruiter-only companies would be invisible otherwise: they are absent
330
- // from `organizations` by design, and someone who cannot find the company
331
- // cannot find its job shop either.
332
- const recruiterOnly = state.postable.filter((o) => o.yourRole === 'recruiter');
333
- if (recruiterOnly.length > 0) {
334
- info(`you post jobs for (run ${dim('hacklab org jobs --org <slug>')}):`);
335
- for (const o of recruiterOnly) {
336
- console.log(` ${bold(o.name)} ${dim(`/o/${o.slug}`)} ${dim('(recruiter)')}`);
337
- }
338
- }
339
- if (state.claimable.length > 0) {
340
- info(`claimable (run ${dim('hacklab org claim <slug>')}):`);
341
- for (const o of state.claimable) {
342
- console.log(` ${o.name} ${dim(`/o/${o.slug}`)} ${dim(`(${claimReason(o.via)})`)}`);
343
- }
344
- }
345
- else if (state.organizations.length === 0) {
346
- info(`create one with ${dim('hacklab org create')}`);
347
- }
348
- }
349
- async function orgView(args) {
350
- const json = args.includes('--json');
351
- const { value: orgSlug } = extractOption(args, '--org');
352
- const session = await requireSession(json);
353
- const state = await loadState(session, json);
354
- const target = resolveTargetOrg(state.organizations, orgSlug);
355
- if (!target.ok)
356
- fail(json, target.code, target.error);
357
- if (json) {
358
- printJson({ organization: target.org });
359
- return;
360
- }
361
- console.log(renderOrg(target.org, resolveAppUrl(session)).join('\n'));
362
- }
363
- async function orgSet(args) {
364
- const json = args.includes('--json');
365
- const clear = args.includes('--clear');
366
- const { value: orgSlug, rest } = extractOption(args.filter((a) => a !== '--json' && a !== '--clear'), '--org');
367
- const [fieldToken, ...valueParts] = rest;
368
- if (!fieldToken)
369
- usage();
370
- const resolved = resolveCommand(fieldToken, ORG_FIELD_NAMES);
371
- if (resolved.kind === 'ambiguous') {
372
- fail(json, 'invalid_fields', `ambiguous field: ${fieldToken} (${resolved.matches.join(', ')})`);
373
- }
374
- if (resolved.kind === 'unknown') {
375
- fail(json, 'invalid_fields', `unknown field "${fieldToken}" (fields: ${ORG_FIELD_NAMES.join(', ')})`);
376
- }
377
- const spec = ORG_FIELDS.find((f) => f.name === resolved.name);
378
- if (!spec)
379
- usage();
380
- const raw = clear ? '' : valueParts.join(' ');
381
- if (!clear && !raw.trim()) {
382
- fail(json, 'invalid_fields', 'pass a value, or --clear');
383
- }
384
- const normalized = normalizeOrgFieldValue(spec, raw);
385
- if (!normalized.ok)
386
- fail(json, 'invalid_fields', normalized.error);
387
- const session = await requireSession(json);
388
- const state = await loadState(session, json);
389
- const target = resolveTargetOrg(state.organizations, orgSlug);
390
- if (!target.ok)
391
- fail(json, target.code, target.error);
392
- let updated;
393
- try {
394
- updated = await patchOrg(session, target.org.id, {
395
- [spec.key]: normalized.value,
396
- });
397
- }
398
- catch (err) {
399
- fail(json, 'error', err instanceof Error ? err.message : String(err));
400
- }
401
- const warnings = slugWarnings(target.org.slug, updated.slug);
402
- if (json) {
403
- printJson({
404
- updated: [spec.key],
405
- organization: updated,
406
- ...(warnings.length ? { warnings } : {}),
407
- });
408
- return;
409
- }
410
- success(`saved ${spec.name}: ${displayValue(spec, updated[spec.key])}`);
411
- for (const w of warnings)
412
- info(w);
413
- info(dim(`${resolveAppUrl(session)}/o/${updated.slug}`));
414
- }
415
- async function orgApply(args) {
416
- const json = args.includes('--json');
417
- const { value: orgSlug, rest } = extractOption(args.filter((a) => a !== '--json'), '--org');
418
- const path = rest.find((a) => a === '-' || !a.startsWith('-'));
419
- if (!path)
420
- usage();
421
- let content;
422
- try {
423
- content = await readInput(path);
424
- }
425
- catch (err) {
426
- const code = err.code;
427
- const message = code === 'ENOENT' ? `file not found: ${path}` : `could not read ${path}`;
428
- fail(json, 'read_failed', message);
429
- }
430
- let doc;
431
- try {
432
- // YAML is a superset of JSON, so one parser covers both file shapes.
433
- doc = parseYaml(content);
434
- }
435
- catch (err) {
436
- fail(json, 'parse_failed', `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`);
437
- }
438
- const parsed = parseOrgDocument(doc);
439
- if (!parsed.ok)
440
- fail(json, 'invalid_fields', parsed.error);
441
- const session = await requireSession(json);
442
- const state = await loadState(session, json);
443
- const target = resolveTargetOrg(state.organizations, orgSlug);
444
- if (!target.ok)
445
- fail(json, target.code, target.error);
446
- let updated;
447
- try {
448
- updated = await patchOrg(session, target.org.id, parsed.fields);
449
- }
450
- catch (err) {
451
- fail(json, 'error', err instanceof Error ? err.message : String(err));
452
- }
453
- const keys = Object.keys(parsed.fields);
454
- const warnings = slugWarnings(target.org.slug, updated.slug);
455
- if (json) {
456
- printJson({
457
- updated: keys,
458
- organization: updated,
459
- ...(warnings.length ? { warnings } : {}),
460
- });
461
- return;
462
- }
463
- const names = keys
464
- .map((key) => ORG_FIELDS.find((f) => f.key === key)?.name ?? key)
465
- .join(', ');
466
- success(`saved ${keys.length} field${keys.length === 1 ? '' : 's'}: ${names}`);
467
- for (const w of warnings)
468
- info(w);
469
- console.log(renderOrg(updated, resolveAppUrl(session)).join('\n'));
470
- }
471
- async function orgClaimDirect(args) {
472
- const json = args.includes('--json');
473
- const slug = args.find((a) => !a.startsWith('-'));
474
- if (!slug)
475
- fail(json, 'invalid_args', 'pass a company slug to claim');
476
- const session = await requireSession(json);
477
- const state = await loadState(session, json);
478
- // Idempotent: claiming an org you already manage is a no-op success.
479
- const mine = state.organizations.find((o) => o.slug === slug);
480
- if (mine) {
481
- if (json) {
482
- printJson({ organization: mine, alreadyClaimed: true });
483
- return;
484
- }
485
- success(`you already manage ${bold(mine.name)}`);
486
- info(dim(`${resolveAppUrl(session)}/o/${mine.slug}`));
487
- return;
488
- }
489
- const candidate = state.claimable.find((o) => o.slug === slug);
490
- if (!candidate) {
491
- const options = state.claimable.map((o) => o.slug).join(', ');
492
- fail(json, 'not_found', options
493
- ? `"${slug}" isn't claimable by you (claimable: ${options})`
494
- : `"${slug}" isn't claimable by you — create it with \`hacklab org create\``);
495
- }
496
- let claimed;
497
- try {
498
- claimed = await postClaim(session, candidate.id);
499
- }
500
- catch (err) {
501
- fail(json, 'claim_failed', err instanceof Error ? err.message : String(err));
502
- }
503
- if (json) {
504
- printJson({
505
- organization: claimed,
506
- alreadyClaimed: Boolean(claimed.alreadyClaimed),
507
- });
508
- return;
509
- }
510
- success(`you now manage ${bold(claimed.name)}`);
511
- info(dim(`${resolveAppUrl(session)}/o/${claimed.slug}`));
512
- info(`fill it in with ${dim('hacklab org apply <file>')} or ${dim('hacklab org set')}`);
513
- }
514
- const CREATE_FLAGS = ['--name', '--slug', '--website', '--description'];
515
- function hasCreateFlags(args) {
516
- return CREATE_FLAGS.some((f) => args.includes(f));
517
- }
518
- async function orgCreateDirect(args) {
519
- const json = args.includes('--json');
520
- let rest = args.filter((a) => a !== '--json');
521
- const opts = {};
522
- for (const flag of CREATE_FLAGS) {
523
- const extracted = extractOption(rest, flag);
524
- opts[flag.slice(2)] = extracted.value;
525
- rest = extracted.rest;
526
- }
527
- const name = opts.name?.trim();
528
- if (!name)
529
- fail(json, 'invalid_args', 'pass --name <company name>');
530
- const slug = (opts.slug ?? slugify(name)).trim();
531
- if (!SLUG_PATTERN.test(slug)) {
532
- fail(json, 'invalid_fields', 'slug must use lowercase letters, numbers, and hyphens only');
533
- }
534
- const fields = { name, slug };
535
- if (opts.website?.trim())
536
- fields.website = ensureScheme(opts.website.trim());
537
- if (opts.description?.trim())
538
- fields.description = opts.description.trim();
539
- const session = await requireSession(json);
540
- let result;
541
- try {
542
- result = await postCreate(session, fields);
543
- }
544
- catch (err) {
545
- fail(json, 'error', err instanceof Error ? err.message : String(err));
546
- }
547
- if (result.ok) {
548
- if (json) {
549
- printJson({ organization: result.org });
550
- return;
551
- }
552
- success(`created ${bold(result.org.name)}`);
553
- info(dim(`${resolveAppUrl(session)}/o/${result.org.slug}`));
554
- info(`fill it in with ${dim('hacklab org apply <file>')} or ${dim('hacklab org set')}`);
555
- return;
556
- }
557
- // Slug collision: relay what the caller can do about it, in both modes.
558
- const { existing, claimable } = result;
559
- const message = `a company with the slug "${slug}" already exists`;
560
- if (json) {
561
- console.log(JSON.stringify({
562
- schemaVersion: 1,
563
- error: { code: 'slug_exists', message },
564
- existing,
565
- claimable,
566
- }, null, 2));
567
- process.exit(1);
568
- }
569
- error(message);
570
- if (claimable) {
571
- info(`you can claim it: ${dim(`hacklab org claim ${existing.slug}`)}`);
572
- }
573
- else if (existing.claimed) {
574
- info('it is already claimed by someone else — pick a different slug');
575
- }
576
- else {
577
- info("you can't claim it (not a member, and your email domain doesn't match) — pick a different slug");
578
- }
579
- process.exit(1);
580
- }
581
- // ---------------------------------------------------------------------------
582
- // access: who controls this org. list / grant <handle> / revoke <handle>.
583
- // People are named by hacklab handle — the identifier an organizer actually
584
- // knows about a colleague — matching how the server takes them.
585
- function accessUsage() {
586
- error('usage: hacklab org access [list|grant|revoke]');
587
- info(` hacklab org access ${dim('[list] [--org <slug>] [--json]')} who controls it`);
588
- info(` hacklab org access ${dim('grant <handle> [--role admin|recruiter] [--org <slug>] [--json]')}`);
589
- info(` hacklab org access ${dim('revoke <handle> [--org <slug>] [--json]')}`);
590
- hint('admin runs the company · recruiter only reaches `hacklab org jobs`');
591
- hint('revoking yourself is allowed — the last admin is not');
592
- process.exit(1);
593
- }
594
- /** Like fail(), plus the "what to do instead" line on stderr. */
595
- function accessFail(json, code, message) {
596
- if (json)
597
- emitJsonError(code, message);
598
- error(message);
599
- const tip = accessErrorHint(code);
600
- if (tip)
601
- hint(tip);
602
- process.exit(1);
603
- }
604
- /** Trim and drop a leading @, so `@marin` and `marin` both work. */
605
- export function normalizeHandle(raw) {
606
- return raw.trim().replace(/^@+/, '').trim();
607
- }
608
- /** Session + the org these verbs act on, resolved exactly like the other verbs. */
609
- async function accessTarget(orgSlug, json) {
610
- const session = await requireSession(json);
611
- const state = await loadState(session, json);
612
- const target = resolveTargetOrg(state.organizations, orgSlug);
613
- if (!target.ok)
614
- fail(json, target.code, target.error);
615
- return { session, org: target.org };
616
- }
617
- /** `@handle`, falling back to the email when there's no hacker profile yet. */
618
- export function claimantLabel(claimant) {
619
- const handle = claimant.handle?.trim();
620
- return handle ? `@${stripControl(handle)}` : stripControl(claimant.email);
621
- }
622
- /**
623
- * The "granted by" column.
624
- *
625
- * `grantedBy` is a raw user id — the route has no handle for it — so there is
626
- * nothing here to turn it into a name. It prints truncated: long enough to tell
627
- * two granters apart at a glance, short enough not to swamp the row. The full
628
- * value is in `--json`.
629
- */
630
- export function grantedByLabel(grantedBy) {
631
- if (!grantedBy)
632
- return 'original claim';
633
- const id = stripControl(grantedBy);
634
- return `granted by ${id.length > 12 ? `${id.slice(0, 12)}…` : id}`;
635
- }
636
- function accessRows(claimants) {
637
- const rows = claimants.map((c) => ({
638
- who: claimantLabel(c),
639
- name: stripControl(c.displayName?.trim() || ''),
640
- // Its own column, not buried in the meta tail: what someone may do here is
641
- // the first thing you scan this list for.
642
- role: c.role,
643
- meta: [
644
- c.isYou ? 'you' : null,
645
- grantedByLabel(c.grantedBy),
646
- `since ${new Date(c.since).toLocaleDateString()}`,
647
- ]
648
- .filter(Boolean)
649
- .join(' · '),
650
- }));
651
- const whoWidth = Math.max(...rows.map((r) => displayWidth(r.who)));
652
- const nameWidth = Math.max(...rows.map((r) => displayWidth(r.name)));
653
- const roleWidth = Math.max(...rows.map((r) => displayWidth(r.role)));
654
- return rows.map((r) => ` ${bold(padEndTo(r.who, whoWidth))} ${padEndTo(r.name, nameWidth)} ${padEndTo(r.role, roleWidth)} ${dim(r.meta)}`);
655
- }
656
- async function accessList(args) {
657
- const json = args.includes('--json');
658
- const { value: orgSlug } = extractOption(args.filter((a) => a !== '--json'), '--org');
659
- const { session, org } = await accessTarget(orgSlug, json);
660
- const result = await accessRequest(session, 'GET', {
661
- orgSlug: org.slug,
662
- });
663
- if (!result.ok)
664
- accessFail(json, result.code, result.message);
665
- const { organization, claimants, yourRole } = result.data;
666
- if (json) {
667
- printJson({ organization, yourRole, claimants });
668
- return;
669
- }
670
- console.log(` ${bold(organization.name)} ${dim(`/o/${organization.slug}`)}`);
671
- console.log('');
672
- // The route only answers for an org you control, so you are always in here —
673
- // an empty list would mean the org lost its last controller, which revoke
674
- // refuses to allow. Handled anyway rather than crashing on Math.max of [].
675
- if (claimants.length === 0) {
676
- info('nobody controls this company');
677
- }
678
- else {
679
- console.log(accessRows(claimants).join('\n'));
680
- }
681
- console.log('');
682
- info(`grant control with ${dim('hacklab org access grant <handle>')}`);
683
- }
684
- /** Read `--role`, defaulting to admin — the historical meaning of a grant. */
685
- export function parseRoleFlag(args) {
686
- const { value, rest } = extractOption(args, '--role');
687
- if (value === undefined)
688
- return { ok: true, role: 'admin', rest };
689
- const role = value.trim().toLowerCase();
690
- if (!ORG_ROLES.includes(role)) {
691
- return {
692
- ok: false,
693
- error: `--role must be one of: ${ORG_ROLES.join(', ')}`,
694
- };
695
- }
696
- return { ok: true, role: role, rest };
697
- }
698
- async function accessGrant(args) {
699
- const json = args.includes('--json');
700
- const { value: orgSlug, rest: afterOrg } = extractOption(args.filter((a) => a !== '--json'), '--org');
701
- const parsedRole = parseRoleFlag(afterOrg);
702
- if (!parsedRole.ok)
703
- fail(json, 'invalid_args', parsedRole.error);
704
- const { role, rest } = parsedRole;
705
- const raw = rest.find((a) => !a.startsWith('-'));
706
- const handle = raw ? normalizeHandle(raw) : '';
707
- if (!handle) {
708
- fail(json, 'invalid_args', 'pass the handle to grant control to');
709
- }
710
- const { session, org } = await accessTarget(orgSlug, json);
711
- const result = await accessRequest(session, 'POST', {}, { orgSlug: org.slug, handle, role });
712
- if (!result.ok)
713
- accessFail(json, result.code, result.message);
714
- const { status } = result.data;
715
- if (json) {
716
- printJson({
717
- organization: { id: org.id, name: org.name, slug: org.slug },
718
- handle,
719
- role,
720
- status,
721
- });
722
- return;
723
- }
724
- if (status === 'already_claimed') {
725
- success(`@${handle} is already ${roleArticle(role)} on ${bold(org.name)}`);
726
- return;
727
- }
728
- if (status === 'role_changed') {
729
- success(`@${handle} is now ${roleArticle(role)} on ${bold(org.name)}`);
730
- }
731
- else {
732
- success(`@${handle} can now act for ${bold(org.name)} as ${role}`);
733
- }
734
- info(role === 'admin'
735
- ? `they can edit it with ${dim('hacklab org')}`
736
- : `they can post listings with ${dim('hacklab org jobs post')}`);
737
- }
738
- /** "an admin" / "a recruiter" — the grammar differs, so it lives in one place. */
739
- export function roleArticle(role) {
740
- return role === 'admin' ? 'an admin' : 'a recruiter';
741
- }
742
- async function accessRevoke(args) {
743
- const json = args.includes('--json');
744
- const { value: orgSlug, rest } = extractOption(args.filter((a) => a !== '--json'), '--org');
745
- const raw = rest.find((a) => !a.startsWith('-'));
746
- const handle = raw ? normalizeHandle(raw) : '';
747
- if (!handle) {
748
- fail(json, 'invalid_args', 'pass the handle to revoke control from');
749
- }
750
- const { session, org } = await accessTarget(orgSlug, json);
751
- const result = await accessRequest(session, 'DELETE', {
752
- orgSlug: org.slug,
753
- handle,
754
- });
755
- if (!result.ok)
756
- accessFail(json, result.code, result.message);
757
- if (json) {
758
- printJson({
759
- organization: { id: org.id, name: org.name, slug: org.slug },
760
- handle,
761
- status: result.data.status,
762
- });
763
- return;
764
- }
765
- // Removing yourself is a supported move, and it reads very differently.
766
- if (session.handle && normalizeHandle(session.handle) === handle) {
767
- success(`you no longer control ${bold(org.name)}`);
768
- return;
769
- }
770
- success(`@${handle} no longer controls ${bold(org.name)}`);
771
- }
772
- async function orgAccess(args) {
773
- const [subToken, ...rest] = args;
774
- // Bare `org access` (and `org access --json`) is the list — the cheapest read.
775
- if (!subToken || subToken.startsWith('-'))
776
- return accessList(args);
777
- const resolved = resolveCommand(subToken, ACCESS_SUBCOMMANDS);
778
- if (resolved.kind === 'ambiguous') {
779
- error(`ambiguous: org access ${subToken} (${resolved.matches.join(', ')})`);
780
- process.exit(1);
781
- }
782
- if (resolved.kind === 'unknown') {
783
- error(`unknown subcommand: org access ${subToken}`);
784
- accessUsage();
785
- }
786
- if (resolved.name === 'list')
787
- return accessList(rest);
788
- if (resolved.name === 'grant')
789
- return accessGrant(rest);
790
- if (resolved.name === 'revoke')
791
- return accessRevoke(rest);
792
- }
793
- /**
794
- * Hand `org jobs` its target company.
795
- *
796
- * Resolved against `postable`, not `organizations`, so a recruiter — who has
797
- * no editable org at all — still lands on the company they were given the job
798
- * shop for. Everything else about the resolution (an explicit `--org`, the
799
- * single-company default, the ambiguity error) is the shared one.
800
- */
801
- async function orgJobsCommand(args) {
802
- const json = args.includes('--json');
803
- const { value: orgSlug, rest } = extractOption(args, '--org');
804
- const session = await requireSession(json);
805
- const state = await loadState(session, json);
806
- const target = resolveTargetOrg(state.postable, orgSlug);
807
- if (!target.ok)
808
- fail(json, target.code, target.error);
809
- return orgJobs({ session, org: target.org }, rest);
810
- }
811
- // ---------------------------------------------------------------------------
812
- // Interactive flows (unchanged behavior): the human editor and claim/create.
813
- // Prompt for one field, prefilled with the current value. Returns the new
814
- // value to send, or a clack cancel symbol.
815
- async function promptField(spec, current) {
816
- if (spec.type === 'boolean') {
817
- return clack.confirm({
818
- message: spec.label,
819
- initialValue: Boolean(current),
820
- });
821
- }
822
- if (spec.type === 'list') {
823
- const initial = Array.isArray(current) ? current.join(', ') : '';
824
- const value = await clack.text({
825
- message: `${spec.label} ${dim('(comma-separated, blank to clear)')}`,
826
- initialValue: initial,
827
- });
828
- if (clack.isCancel(value))
829
- return value;
830
- const items = value
831
- .split(',')
832
- .map((s) => s.trim())
833
- .filter(Boolean);
834
- return items.length ? items : null;
835
- }
836
- if (spec.type === 'number') {
837
- const value = await clack.text({
838
- message: `${spec.label} ${dim('(blank to clear)')}`,
839
- initialValue: current == null ? '' : String(current),
840
- validate: (v) => {
841
- const t = (v ?? '').trim();
842
- return !t || /^\d+$/.test(t) ? undefined : 'must be a whole number';
843
- },
844
- });
845
- if (clack.isCancel(value))
846
- return value;
847
- return value.trim() ? Number(value.trim()) : null;
848
- }
849
- const value = await clack.text({
850
- message: spec.type === 'url'
851
- ? `${spec.label} ${dim('(blank to clear)')}`
852
- : spec.label,
853
- initialValue: current == null ? '' : String(current),
854
- validate: spec.type === 'url'
855
- ? (v) => {
856
- const t = (v ?? '').trim();
857
- if (!t)
858
- return undefined;
859
- try {
860
- new URL(t);
861
- return undefined;
862
- }
863
- catch {
864
- return 'must be a valid URL (include https://)';
865
- }
866
- }
867
- : undefined,
868
- });
869
- if (clack.isCancel(value))
870
- return value;
871
- const trimmed = value.trim();
872
- // name/slug are required (notNull): a blank keeps the current value.
873
- if ((spec.key === 'name' || spec.key === 'slug') && !trimmed)
874
- return current;
875
- return trimmed ? trimmed : null;
876
- }
877
- // Autosave editor over a claimed org. Editing a field writes it immediately
878
- // (mirrors the web settings' "saves instantly, no Save button"): pick a field,
879
- // set its value, it saves, and the menu shows the saved value. Leave any time
880
- // with "done" or Esc — nothing is pending, so nothing is lost. Prints its own
881
- // outro, so callers don't add another.
882
- async function editLoop(session, initial) {
883
- let target = initial;
884
- const DONE = '__done__';
885
- let savedCount = 0;
886
- while (true) {
887
- const choice = await clack.select({
888
- message: `editing ${bold(target.name)} ${dim('(saves as you go)')}`,
889
- options: [
890
- ...ORG_FIELDS.map((f) => ({
891
- value: f.key,
892
- label: f.label,
893
- hint: displayValue(f, target[f.key]),
894
- })),
895
- { value: DONE, label: '← done' },
896
- ],
897
- });
898
- // Esc / Ctrl+C at the menu, or "done": leave. Every edit is already saved.
899
- if (clack.isCancel(choice) || choice === DONE)
900
- break;
901
- const spec = ORG_FIELDS.find((f) => f.key === choice);
902
- if (!spec)
903
- continue;
904
- const next = await promptField(spec, target[spec.key]);
905
- // Cancelling a single field drops just that edit and returns to the menu.
906
- if (clack.isCancel(next))
907
- continue;
908
- const label = spec.label.toLowerCase();
909
- const prevSlug = target.slug;
910
- const spin = clack.spinner();
911
- spin.start(`saving ${label}`);
912
- try {
913
- const updated = await patchOrg(session, target.id, {
914
- [spec.key]: next,
915
- });
916
- target = { ...target, ...updated };
917
- savedCount++;
918
- spin.stop(`saved ${label}`);
919
- if (spec.key === 'slug' && target.slug !== prevSlug) {
920
- clack.log.warn(`old links to /o/${prevSlug} will no longer resolve`);
921
- }
922
- }
923
- catch (err) {
924
- spin.stop(`could not save ${label}`);
925
- error(err instanceof Error ? err.message : String(err));
926
- // Stay in the loop so they can retry or edit something else.
927
- }
928
- }
929
- if (savedCount > 0) {
930
- success(`saved ${savedCount} change${savedCount === 1 ? '' : 's'} to ${bold(target.name)}`);
931
- info(`${resolveAppUrl(session)}/o/${target.slug}`);
932
- }
933
- clack.outro(dim('done.'));
934
- }
935
- // After a claim/create, offer to drop straight into the editor. Either branch
936
- // finishes the command (editLoop or the outro here), so exactly one outro runs.
937
- async function offerEdit(session, org) {
938
- const go = await clack.confirm({
939
- message: 'edit its details now?',
940
- initialValue: false,
941
- });
942
- if (clack.isCancel(go) || !go) {
943
- clack.outro(dim('done.'));
944
- return;
945
- }
946
- await editLoop(session, org);
947
- }
948
- async function claimFlow(session, preloaded) {
949
- let list = preloaded;
950
- if (!list) {
951
- try {
952
- list = (await fetchOrgState(session)).claimable;
953
- }
954
- catch (err) {
955
- clack.cancel(err instanceof Error ? err.message : String(err));
956
- process.exit(1);
957
- }
958
- }
959
- if (list.length === 0) {
960
- clack.cancel("no companies to claim — you're not a member of any unclaimed company, and none match your email domain. create one with `hacklab org create`.");
961
- return;
962
- }
963
- const picked = await clack.select({
964
- message: 'claim which company?',
965
- options: list.map((o) => ({
966
- value: o.id,
967
- label: `${o.name} ${dim(`/o/${o.slug}`)}`,
968
- hint: claimReason(o.via),
969
- })),
970
- });
971
- if (clack.isCancel(picked)) {
972
- clack.outro(dim('cancelled.'));
973
- return;
974
- }
975
- const chosen = list.find((o) => o.id === picked);
976
- if (!chosen) {
977
- clack.outro(dim('cancelled.'));
978
- return;
979
- }
980
- const ok = await clack.confirm({
981
- message: `claim ${bold(chosen.name)}? you'll be able to edit its public profile.`,
982
- });
983
- if (clack.isCancel(ok) || !ok) {
984
- clack.outro(dim('cancelled.'));
985
- return;
986
- }
987
- const spin = clack.spinner();
988
- spin.start(`claiming ${chosen.name}`);
989
- let claimed;
990
- try {
991
- claimed = await postClaim(session, chosen.id);
992
- spin.stop(`claimed ${chosen.name}`);
993
- }
994
- catch (err) {
995
- spin.stop('could not claim');
996
- error(err instanceof Error ? err.message : String(err));
997
- return;
998
- }
999
- success(`you now manage ${bold(claimed.name)}`);
1000
- info(`${resolveAppUrl(session)}/o/${claimed.slug}`);
1001
- await offerEdit(session, claimed);
1002
- }
1003
- async function createFlow(session) {
1004
- const name = await clack.text({
1005
- message: 'company name',
1006
- validate: (v) => ((v ?? '').trim() ? undefined : 'name is required'),
1007
- });
1008
- if (clack.isCancel(name)) {
1009
- clack.outro(dim('cancelled.'));
1010
- return;
1011
- }
1012
- const slug = await clack.text({
1013
- message: `slug ${dim('(url handle)')}`,
1014
- initialValue: slugify(name),
1015
- validate: (v) => {
1016
- const t = (v ?? '').trim();
1017
- if (!t)
1018
- return 'slug is required';
1019
- if (!SLUG_PATTERN.test(t)) {
1020
- return 'lowercase letters, numbers, and hyphens only';
1021
- }
1022
- return undefined;
1023
- },
1024
- });
1025
- if (clack.isCancel(slug)) {
1026
- clack.outro(dim('cancelled.'));
1027
- return;
1028
- }
1029
- const website = await clack.text({
1030
- message: `website ${dim('(blank to skip)')}`,
1031
- validate: (v) => {
1032
- const t = (v ?? '').trim();
1033
- if (!t)
1034
- return undefined;
1035
- try {
1036
- new URL(t);
1037
- return undefined;
1038
- }
1039
- catch {
1040
- return 'must be a valid URL (include https://)';
1041
- }
1042
- },
1043
- });
1044
- if (clack.isCancel(website)) {
1045
- clack.outro(dim('cancelled.'));
1046
- return;
1047
- }
1048
- const description = await clack.text({
1049
- message: `short description ${dim('(blank to skip)')}`,
1050
- });
1051
- if (clack.isCancel(description)) {
1052
- clack.outro(dim('cancelled.'));
1053
- return;
1054
- }
1055
- const fields = {
1056
- name: name.trim(),
1057
- slug: slug.trim(),
1058
- };
1059
- if (website.trim())
1060
- fields.website = website.trim();
1061
- if (description.trim())
1062
- fields.description = description.trim();
1063
- const spin = clack.spinner();
1064
- spin.start(`creating ${name.trim()}`);
1065
- let result;
1066
- try {
1067
- result = await postCreate(session, fields);
1068
- }
1069
- catch (err) {
1070
- spin.stop('could not create');
1071
- error(err instanceof Error ? err.message : String(err));
1072
- return;
1073
- }
1074
- if (result.ok) {
1075
- spin.stop(`created ${result.org.name}`);
1076
- success(`you now manage ${bold(result.org.name)}`);
1077
- info(`${resolveAppUrl(session)}/o/${result.org.slug}`);
1078
- await offerEdit(session, result.org);
1079
- return;
1080
- }
1081
- // Slug collision. If the existing org is unclaimed and the caller is a member,
1082
- // offer to claim it instead of forcing a rename.
1083
- spin.stop(`"${slug.trim()}" already exists`);
1084
- const { existing, claimable } = result;
1085
- if (claimable) {
1086
- const go = await clack.confirm({
1087
- message: `${bold(existing.name)} already exists and you can claim it — claim it instead?`,
1088
- });
1089
- if (clack.isCancel(go) || !go) {
1090
- clack.outro(dim('cancelled.'));
1091
- return;
1092
- }
1093
- const spin2 = clack.spinner();
1094
- spin2.start(`claiming ${existing.name}`);
1095
- let claimed;
1096
- try {
1097
- claimed = await postClaim(session, existing.id);
1098
- spin2.stop(`claimed ${existing.name}`);
1099
- }
1100
- catch (err) {
1101
- spin2.stop('could not claim');
1102
- error(err instanceof Error ? err.message : String(err));
1103
- return;
1104
- }
1105
- success(`you now manage ${bold(claimed.name)}`);
1106
- info(`${resolveAppUrl(session)}/o/${claimed.slug}`);
1107
- await offerEdit(session, claimed);
1108
- return;
1109
- }
1110
- // Can't claim it: either someone else owns it, or the caller has no claim
1111
- // (not a member and email domain doesn't match).
1112
- if (existing.claimed) {
1113
- error(`${existing.name} is already claimed by someone else — pick a different slug.`);
1114
- }
1115
- else {
1116
- error(`${existing.name} already exists but you can't claim it (not a member, and your email domain doesn't match). pick a different slug, or ask to be added as a member.`);
1117
- }
1118
- clack.outro(dim('done.'));
1119
- }
1120
- async function editFlow(session) {
1121
- let state;
1122
- try {
1123
- state = await fetchOrgState(session);
1124
- }
1125
- catch (err) {
1126
- clack.cancel(err instanceof Error ? err.message : String(err));
1127
- process.exit(1);
1128
- }
1129
- const { organizations, claimable } = state;
1130
- // Don't manage anything yet → offer claim (if any) / create instead of a
1131
- // dead end pointing at the website.
1132
- if (organizations.length === 0) {
1133
- const options = [];
1134
- if (claimable.length > 0) {
1135
- options.push({
1136
- value: 'claim',
1137
- label: `claim an existing company ${dim(`(${claimable.length})`)}`,
1138
- });
1139
- }
1140
- options.push({ value: 'create', label: 'create a new company' });
1141
- options.push({ value: 'cancel', label: 'cancel' });
1142
- const choice = await clack.select({
1143
- message: "you don't manage a company yet",
1144
- options,
1145
- });
1146
- if (clack.isCancel(choice) || choice === 'cancel') {
1147
- clack.outro(dim('cancelled.'));
1148
- return;
1149
- }
1150
- if (choice === 'claim') {
1151
- await claimFlow(session, claimable);
1152
- return;
1153
- }
1154
- await createFlow(session);
1155
- return;
1156
- }
1157
- let target = organizations[0];
1158
- if (organizations.length > 1) {
1159
- const picked = await clack.select({
1160
- message: 'which organization?',
1161
- options: organizations.map((o) => ({
1162
- value: o.id,
1163
- label: `${o.name} ${dim(`/o/${o.slug}`)}`,
1164
- })),
1165
- });
1166
- if (clack.isCancel(picked)) {
1167
- clack.outro(dim('cancelled.'));
1168
- return;
1169
- }
1170
- target = organizations.find((o) => o.id === picked) ?? target;
1171
- }
1172
- await editLoop(session, target);
1173
- }
1174
- async function orgInteractive(mode) {
1175
- const session = await requireSession(false);
1176
- if (mode === 'claim') {
1177
- clack.intro(bold('hacklab org claim'));
1178
- await claimFlow(session);
1179
- return;
1180
- }
1181
- if (mode === 'create') {
1182
- clack.intro(bold('hacklab org create'));
1183
- // Validate the session against the *target* backend before collecting the
1184
- // whole form, so a per-backend token mismatch (the common "I'm logged in but
1185
- // it says Unauthorized" case) fails fast with a fix hint instead of after
1186
- // you've filled everything in.
1187
- try {
1188
- await fetchOrgState(session);
1189
- }
1190
- catch (err) {
1191
- clack.cancel(err instanceof Error ? err.message : String(err));
1192
- process.exit(1);
1193
- }
1194
- await createFlow(session);
1195
- return;
1196
- }
1197
- clack.intro(bold('hacklab org'));
1198
- await editFlow(session);
1199
- }
1200
- export async function org(args = []) {
1201
- const [subToken, ...rest] = args;
1202
- // Bare `hacklab org` keeps the interactive hub; bare + --json means view,
1203
- // so `hacklab org --json` is the cheapest agent read.
1204
- if (!subToken)
1205
- return orgInteractive('edit');
1206
- if (subToken.startsWith('-')) {
1207
- if (args.includes('--json'))
1208
- return orgView(args);
1209
- return orgInteractive('edit');
1210
- }
1211
- const resolved = resolveCommand(subToken, SUBCOMMANDS);
1212
- if (resolved.kind === 'ambiguous') {
1213
- error(`ambiguous: org ${subToken} (${resolved.matches.join(', ')})`);
1214
- process.exit(1);
1215
- }
1216
- if (resolved.kind === 'unknown') {
1217
- error(`unknown subcommand: org ${subToken}`);
1218
- usage();
1219
- }
1220
- if (resolved.name === 'list')
1221
- return orgList(rest);
1222
- if (resolved.name === 'view')
1223
- return orgView(rest);
1224
- if (resolved.name === 'set')
1225
- return orgSet(rest);
1226
- if (resolved.name === 'apply')
1227
- return orgApply(rest);
1228
- if (resolved.name === 'edit')
1229
- return orgInteractive('edit');
1230
- if (resolved.name === 'access')
1231
- return orgAccess(rest);
1232
- if (resolved.name === 'jobs')
1233
- return orgJobsCommand(rest);
1234
- if (resolved.name === 'claim') {
1235
- // A slug (or --json) makes it non-interactive; bare `claim` keeps the picker.
1236
- const direct = rest.some((a) => !a.startsWith('-')) || rest.includes('--json');
1237
- return direct ? orgClaimDirect(rest) : orgInteractive('claim');
1238
- }
1239
- if (resolved.name === 'create') {
1240
- const direct = hasCreateFlags(rest) || rest.includes('--json');
1241
- return direct ? orgCreateDirect(rest) : orgInteractive('create');
1242
- }
1243
- }
1244
- //# sourceMappingURL=org.js.map