@indigoai-us/hq-cli 5.345.155 → 5.345.159
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +289 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +110 -2
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +101 -5
- package/assets/scaffold/core/scripts/worktree.sh +4 -0
- package/dist/command-catalog.generated.d.ts +536 -7
- package/dist/command-catalog.generated.js +676 -24
- package/dist/command-registration-plan.d.ts +18 -0
- package/dist/command-registration-plan.js +4 -0
- package/dist/commands/_patterns.d.ts +17 -0
- package/dist/commands/_patterns.js +22 -0
- package/dist/commands/agent-inbox.js +2 -2
- package/dist/commands/agent-kit.js +8 -6
- package/dist/commands/bot.js +7 -4
- package/dist/commands/channel-suggest.d.ts +56 -0
- package/dist/commands/channel-suggest.js +170 -0
- package/dist/commands/channels.js +7 -1
- package/dist/commands/cloud.d.ts +45 -0
- package/dist/commands/cloud.js +150 -10
- package/dist/commands/company.js +4 -3
- package/dist/commands/core-checkpoint-native.d.ts +18 -0
- package/dist/commands/core-checkpoint-native.js +67 -2
- package/dist/commands/core-pipeline-driver-native.d.ts +12 -1
- package/dist/commands/core-pipeline-driver-native.js +131 -5
- package/dist/commands/core.js +13 -1
- package/dist/commands/crm.js +83 -6
- package/dist/commands/deploy-access.d.ts +33 -0
- package/dist/commands/deploy-access.js +322 -0
- package/dist/commands/dm-attach.d.ts +106 -0
- package/dist/commands/dm-attach.js +184 -0
- package/dist/commands/dm.d.ts +37 -0
- package/dist/commands/dm.js +172 -10
- package/dist/commands/feedback-show.d.ts +40 -0
- package/dist/commands/feedback-show.js +261 -0
- package/dist/commands/feedback.d.ts +21 -0
- package/dist/commands/feedback.js +152 -2
- package/dist/commands/files-browse.d.ts +16 -0
- package/dist/commands/files-browse.js +52 -1
- package/dist/commands/files.d.ts +24 -0
- package/dist/commands/files.js +52 -1
- package/dist/commands/integrations-connect.js +7 -3
- package/dist/commands/integrations-core.d.ts +13 -1
- package/dist/commands/integrations-core.js +49 -4
- package/dist/commands/integrations.js +4 -2
- package/dist/commands/lanes-accounts.js +16 -1
- package/dist/commands/lanes-loop.js +14 -4
- package/dist/commands/lanes-message.js +6 -4
- package/dist/commands/lanes-resume.js +36 -13
- package/dist/commands/lanes-shared.d.ts +5 -0
- package/dist/commands/lanes-shared.js +13 -3
- package/dist/commands/lanes-watch.js +1 -0
- package/dist/commands/lanes.d.ts +20 -1
- package/dist/commands/lanes.js +201 -51
- package/dist/commands/mesh.js +149 -1
- package/dist/commands/monitor.d.ts +1 -0
- package/dist/commands/monitor.js +4 -1
- package/dist/commands/repo.d.ts +41 -0
- package/dist/commands/repo.js +331 -0
- package/dist/commands/scan.d.ts +3 -1
- package/dist/commands/scan.js +7 -3
- package/dist/commands/secrets.d.ts +37 -0
- package/dist/commands/secrets.js +185 -16
- package/dist/commands/signals.js +17 -5
- package/dist/commands/staff/listings.d.ts +41 -0
- package/dist/commands/staff/listings.js +160 -0
- package/dist/commands/staff/reset-test-account.d.ts +3 -2
- package/dist/commands/staff/reset-test-account.js +4 -3
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2629 -1036
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
- package/dist/lib/agent-kit/inbox-state.js +5 -1
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/agent-kit/run/inbox.js +2 -1
- package/dist/lib/agent-kit/wake-secrets.d.ts +7 -0
- package/dist/lib/agent-kit/wake-secrets.js +32 -0
- package/dist/lib/agent-kit/wake.d.ts +4 -0
- package/dist/lib/agent-kit/wake.js +49 -3
- package/dist/lib/bot/inflight.d.ts +7 -1
- package/dist/lib/bot/inflight.js +10 -2
- package/dist/lib/bot/prompt.js +3 -0
- package/dist/lib/bot/run.js +72 -20
- package/dist/lib/bot/session.d.ts +14 -0
- package/dist/lib/bot/session.js +70 -0
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +6 -0
- package/dist/lib/capacity/collectors/claude-oauth.js +12 -0
- package/dist/lib/capacity/lane-login.d.ts +6 -0
- package/dist/lib/capacity/lane-login.js +5 -0
- package/dist/lib/core-utils/worktree.js +2 -0
- package/dist/lib/daemon/exit-cause.d.ts +43 -0
- package/dist/lib/daemon/exit-cause.js +88 -0
- package/dist/lib/daemon/handoff.d.ts +2 -0
- package/dist/lib/daemon/handoff.js +6 -1
- package/dist/lib/daemon/hq-child-bootstrap.mjs +20 -3
- package/dist/lib/daemon/paths.d.ts +2 -0
- package/dist/lib/daemon/paths.js +1 -0
- package/dist/lib/daemon/run.js +13 -0
- package/dist/lib/daemon/supervisor.d.ts +54 -1
- package/dist/lib/daemon/supervisor.js +297 -21
- package/dist/lib/daemon/unit-holds.d.ts +22 -0
- package/dist/lib/daemon/unit-holds.js +86 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
- package/dist/lib/doctor/checks/sync-health.js +66 -1
- package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
- package/dist/lib/doctor/company-resolution-state.js +89 -0
- package/dist/lib/doctor/fix/apply.d.ts +36 -6
- package/dist/lib/doctor/fix/apply.js +128 -12
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +1 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/hook-engine/resolve.js +6 -2
- package/dist/lib/index-render/company-knowledge.d.ts +1 -1
- package/dist/lib/index-render/company-knowledge.js +4 -5
- package/dist/lib/index-render/index.js +44 -3
- package/dist/lib/index-render/projects.d.ts +1 -1
- package/dist/lib/index-render/projects.js +5 -5
- package/dist/lib/lanes/account-company.d.ts +28 -0
- package/dist/lib/lanes/account-company.js +53 -0
- package/dist/lib/lanes/accounts.d.ts +22 -3
- package/dist/lib/lanes/accounts.js +38 -2
- package/dist/lib/lanes/claude-balance.d.ts +6 -1
- package/dist/lib/lanes/claude-balance.js +6 -1
- package/dist/lib/lanes/claude-credential.d.ts +78 -0
- package/dist/lib/lanes/claude-credential.js +163 -0
- package/dist/lib/lanes/compaction.d.ts +2 -0
- package/dist/lib/lanes/compaction.js +9 -0
- package/dist/lib/lanes/config.js +6 -0
- package/dist/lib/lanes/envelope-repair-dispatch.js +2 -1
- package/dist/lib/lanes/hooks-install.js +2 -1
- package/dist/lib/lanes/host-capacity.d.ts +2 -0
- package/dist/lib/lanes/host-capacity.js +1 -0
- package/dist/lib/lanes/keep-alive.d.ts +1 -1
- package/dist/lib/lanes/keep-alive.js +3 -1
- package/dist/lib/lanes/links.js +10 -4
- package/dist/lib/lanes/loop.js +16 -1
- package/dist/lib/lanes/node-binary.d.ts +17 -0
- package/dist/lib/lanes/node-binary.js +30 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
- package/dist/lib/lanes/outpost-dispatch.js +1 -0
- package/dist/lib/lanes/reconcile.js +4 -0
- package/dist/lib/lanes/remote-control-lane.js +2 -0
- package/dist/lib/lanes/spawn.d.ts +3 -0
- package/dist/lib/lanes/spawn.js +119 -9
- package/dist/lib/lanes/store.d.ts +6 -0
- package/dist/lib/lanes/store.js +107 -18
- package/dist/lib/lanes/types.d.ts +2 -0
- package/dist/lib/lanes/watch.js +28 -5
- package/dist/lib/lanes/watcher-main.js +4 -3
- package/dist/lib/lanes/watcher.d.ts +4 -0
- package/dist/lib/lanes/watcher.js +110 -21
- package/dist/lib/mesh/api.d.ts +32 -0
- package/dist/lib/mesh/api.js +75 -0
- package/dist/lib/secret-scan/detect.js +9 -0
- package/dist/lib/secret-scan/repo-config.d.ts +41 -0
- package/dist/lib/secret-scan/repo-config.js +237 -0
- package/dist/lib/secret-scan/rules.d.ts +7 -1
- package/dist/lib/secret-scan/rules.js +3 -1
- package/dist/lib/secret-scan/staged.d.ts +5 -0
- package/dist/lib/secret-scan/staged.js +19 -2
- package/dist/lib/secret-scan/types.d.ts +2 -0
- package/dist/lib/sync/server-owned-local-only.d.ts +29 -0
- package/dist/lib/sync/server-owned-local-only.js +75 -0
- package/dist/sentry-before-send.js +1 -0
- package/dist/utils/access-denied-hint.js +6 -0
- package/dist/utils/client-health.d.ts +34 -0
- package/dist/utils/client-health.js +96 -3
- package/dist/utils/package-use-lease.js +27 -9
- package/dist/utils/version-gate.d.ts +7 -0
- package/dist/utils/version-gate.js +66 -0
- package/package.json +8 -8
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { deployApiRequest } from "../lib/deploy-api.js";
|
|
3
|
+
import { listDeployApps } from "../lib/deploy-apps.js";
|
|
4
|
+
import { DEFAULT_HQ_ROOT, ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
|
+
import { getCompanyUid, looksLikeCompanyUid } from "../utils/vault-api.js";
|
|
6
|
+
import { displayText, safeJson } from "./deploy-comments.js";
|
|
7
|
+
import { readActiveCompanySlug } from "./skill.js";
|
|
8
|
+
/**
|
|
9
|
+
* `hq deploy access` reads and changes a deploy app's access policy through
|
|
10
|
+
* hq-deploy's GET/PUT /api/apps/:id/access-policy (the same API the HQ console
|
|
11
|
+
* access modal uses). Feedback bcd64202-071a-4d01-9005-3329a13ca4ae.
|
|
12
|
+
*/
|
|
13
|
+
export const DEPLOY_ACCESS_MODES = ["company", "selected", "public"];
|
|
14
|
+
class DeployAccessError extends Error {
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Terminal hooks for the approval prompt. Tests replace these; production reads
|
|
18
|
+
* the real TTY state and asks on stdin.
|
|
19
|
+
*/
|
|
20
|
+
export const deployAccessPrompt = {
|
|
21
|
+
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
22
|
+
ask: async (question) => {
|
|
23
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
24
|
+
try {
|
|
25
|
+
return await rl.question(question);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
rl.close();
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Changes that make an app public or drop its password gate need explicit
|
|
34
|
+
* approval: --yes, or an interactive "yes". Non-interactive without --yes
|
|
35
|
+
* fails closed before any write.
|
|
36
|
+
*/
|
|
37
|
+
async function requireApproval(app, current, record, mode, yes) {
|
|
38
|
+
// DeployAccessMode never includes password, so any change from a password
|
|
39
|
+
// app removes the password.
|
|
40
|
+
const removesPassword = current.mode === "password" || record?.passwordProtected === true;
|
|
41
|
+
const goesPublic = mode === "public" && current.mode !== "public";
|
|
42
|
+
if (!removesPassword && !goesPublic)
|
|
43
|
+
return;
|
|
44
|
+
const name = displayText(app);
|
|
45
|
+
const from = displayText(current.mode ?? "unknown");
|
|
46
|
+
const suffix = mode === "public" ? " (anyone with the link can open it)" : "";
|
|
47
|
+
console.error(`This changes access for '${name}' from ${from} to ${mode}${suffix}.`);
|
|
48
|
+
if (removesPassword) {
|
|
49
|
+
console.error(`Warning: this removes the password protection on '${name}'.`);
|
|
50
|
+
}
|
|
51
|
+
if (yes)
|
|
52
|
+
return;
|
|
53
|
+
if (!deployAccessPrompt.isInteractive()) {
|
|
54
|
+
throw new DeployAccessError(`Refusing to change access for '${name}' without approval. Re-run with --yes to confirm. Nothing was changed.`);
|
|
55
|
+
}
|
|
56
|
+
const answer = (await deployAccessPrompt.ask("Type 'yes' to continue: ")).trim().toLowerCase();
|
|
57
|
+
if (answer !== "yes" && answer !== "y") {
|
|
58
|
+
throw new DeployAccessError("Cancelled. Nothing was changed.");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
62
|
+
function resolveCompany(companyFlag) {
|
|
63
|
+
const slug = companyFlag ?? readActiveCompanySlug(DEFAULT_HQ_ROOT);
|
|
64
|
+
if (slug === undefined)
|
|
65
|
+
throw new DeployAccessError("No active company. Pass --company <slug>.");
|
|
66
|
+
if (!SLUG_PATTERN.test(slug))
|
|
67
|
+
throw new DeployAccessError("The company slug is not valid.");
|
|
68
|
+
return slug;
|
|
69
|
+
}
|
|
70
|
+
export function parseMode(raw) {
|
|
71
|
+
if (DEPLOY_ACCESS_MODES.includes(raw))
|
|
72
|
+
return raw;
|
|
73
|
+
throw new DeployAccessError(`Unknown access mode '${displayText(raw)}'. Use one of: ${DEPLOY_ACCESS_MODES.join(", ")}.`);
|
|
74
|
+
}
|
|
75
|
+
function str(value) {
|
|
76
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
77
|
+
}
|
|
78
|
+
function grantees(value) {
|
|
79
|
+
if (!Array.isArray(value))
|
|
80
|
+
return [];
|
|
81
|
+
return value
|
|
82
|
+
.map((item) => (item && typeof item === "object" ? str(item.id) : null))
|
|
83
|
+
.filter((id) => id !== null)
|
|
84
|
+
.map((id) => ({ id }));
|
|
85
|
+
}
|
|
86
|
+
export function toView(body) {
|
|
87
|
+
return {
|
|
88
|
+
appId: str(body.appId),
|
|
89
|
+
mode: str(body.mode),
|
|
90
|
+
// Only a saved policy carries a real company uid. hq-deploy returns an
|
|
91
|
+
// empty companyUid when no policy is saved; orgId is the hq-deploy org id,
|
|
92
|
+
// which the PUT rejects (ACCESS_POLICY_COMPANY_MISMATCH).
|
|
93
|
+
companyUid: str(body.companyUid),
|
|
94
|
+
users: grantees(body.users),
|
|
95
|
+
groups: grantees(body.groups),
|
|
96
|
+
policyVersion: typeof body.policyVersion === "number" ? body.policyVersion : null,
|
|
97
|
+
updatedAt: str(body.updatedAt),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function collect(value, previous = []) {
|
|
101
|
+
const id = value.trim();
|
|
102
|
+
if (id.length === 0)
|
|
103
|
+
throw new DeployAccessError("Member ids must not be empty.");
|
|
104
|
+
return [...previous, id];
|
|
105
|
+
}
|
|
106
|
+
async function errorFor(response, slug, what) {
|
|
107
|
+
const body = await response.json().catch(() => ({}));
|
|
108
|
+
const raw = body.error?.code;
|
|
109
|
+
const code = typeof raw === "string" && /^[A-Z0-9_]+$/.test(raw) ? raw : `HTTP_${response.status}`;
|
|
110
|
+
if (response.status === 401) {
|
|
111
|
+
return new DeployAccessError(`hq-deploy rejected your sign-in. Run 'hq login' and retry. (${code})`);
|
|
112
|
+
}
|
|
113
|
+
if (response.status === 403) {
|
|
114
|
+
return new DeployAccessError(`You need to be the app owner or a company admin in '${slug}' to manage access. (${code})`);
|
|
115
|
+
}
|
|
116
|
+
if (response.status === 404)
|
|
117
|
+
return new DeployAccessError(`App not found in '${slug}'. (${code})`);
|
|
118
|
+
if (response.status === 400) {
|
|
119
|
+
const message = typeof body.error?.message === "string" ? displayText(body.error.message) : "invalid request";
|
|
120
|
+
return new DeployAccessError(`hq-deploy refused the change: ${message} (${code})`);
|
|
121
|
+
}
|
|
122
|
+
const message = typeof body.error?.message === "string" ? `: ${displayText(body.error.message)}` : "";
|
|
123
|
+
return new DeployAccessError(`Could not ${what} (HTTP ${response.status}, ${code})${message}.`);
|
|
124
|
+
}
|
|
125
|
+
async function request(token, slug, route, method, body) {
|
|
126
|
+
try {
|
|
127
|
+
return await deployApiRequest(token, route, method, body, { "X-Org-Slug": slug });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new DeployAccessError("Could not reach hq-deploy.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function readJson(response) {
|
|
134
|
+
const body = await response.json().catch(() => null);
|
|
135
|
+
if (!body || typeof body !== "object")
|
|
136
|
+
throw new DeployAccessError("hq-deploy returned an invalid response.");
|
|
137
|
+
return body;
|
|
138
|
+
}
|
|
139
|
+
/** Resolve an app id, subdomain or exact name to its hq-deploy app id. */
|
|
140
|
+
async function resolveApp(token, slug, app) {
|
|
141
|
+
if (/^app_[A-Za-z0-9_-]+$/.test(app))
|
|
142
|
+
return { id: app, record: null };
|
|
143
|
+
const apps = await listDeployApps(async (cursor) => {
|
|
144
|
+
const route = cursor ? `/api/apps?cursor=${encodeURIComponent(cursor)}` : "/api/apps";
|
|
145
|
+
const response = await request(token, slug, route, "GET");
|
|
146
|
+
if (!response.ok)
|
|
147
|
+
throw await errorFor(response, slug, "list deploy apps");
|
|
148
|
+
return readJson(response);
|
|
149
|
+
});
|
|
150
|
+
const needle = app.toLowerCase();
|
|
151
|
+
const matches = apps
|
|
152
|
+
.filter((item) => typeof item === "object" && item !== null)
|
|
153
|
+
.filter((item) => [item.id, item.subdomain, item.name].some((field) => typeof field === "string" && field.toLowerCase() === needle));
|
|
154
|
+
const ids = [...new Set(matches.map((item) => str(item.id)).filter((id) => id !== null))];
|
|
155
|
+
if (ids.length === 0)
|
|
156
|
+
throw new DeployAccessError(`No deploy app '${displayText(app)}' in '${slug}'.`);
|
|
157
|
+
if (ids.length > 1) {
|
|
158
|
+
throw new DeployAccessError(`'${displayText(app)}' matches ${ids.length} apps in '${slug}'. Use the app id.`);
|
|
159
|
+
}
|
|
160
|
+
const id = ids[0];
|
|
161
|
+
return { id, record: matches.find((item) => item.id === id) ?? null };
|
|
162
|
+
}
|
|
163
|
+
async function resolveAppId(token, slug, app) {
|
|
164
|
+
return (await resolveApp(token, slug, app)).id;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The hq-pro company uid the PUT must carry. Order: the saved policy, the app
|
|
168
|
+
* record, then the company slug (--company or the active company) resolved in
|
|
169
|
+
* the caller's own companies. Never the hq-deploy orgId.
|
|
170
|
+
*/
|
|
171
|
+
async function resolvePolicyCompanyUid(token, slug, policy, record) {
|
|
172
|
+
const fromPolicy = policy.companyUid;
|
|
173
|
+
if (fromPolicy && looksLikeCompanyUid(fromPolicy))
|
|
174
|
+
return fromPolicy;
|
|
175
|
+
const fromApp = record ? str(record.companyUid) : null;
|
|
176
|
+
if (fromApp && looksLikeCompanyUid(fromApp))
|
|
177
|
+
return fromApp;
|
|
178
|
+
const resolved = await getCompanyUid(token, slug).catch(() => null);
|
|
179
|
+
if (resolved && looksLikeCompanyUid(resolved))
|
|
180
|
+
return resolved;
|
|
181
|
+
throw new DeployAccessError(`Could not find the HQ company id for '${slug}'. Check --company, run 'hq login', and retry.`);
|
|
182
|
+
}
|
|
183
|
+
async function getPolicy(token, slug, appId) {
|
|
184
|
+
const response = await request(token, slug, `/api/apps/${encodeURIComponent(appId)}/access-policy`, "GET");
|
|
185
|
+
if (!response.ok)
|
|
186
|
+
throw await errorFor(response, slug, "read the access policy");
|
|
187
|
+
return toView(await readJson(response));
|
|
188
|
+
}
|
|
189
|
+
function applyEdits(current, add = [], remove = []) {
|
|
190
|
+
const removeSet = new Set(remove);
|
|
191
|
+
const ids = current.map((g) => g.id).filter((id) => !removeSet.has(id));
|
|
192
|
+
for (const id of add)
|
|
193
|
+
if (!ids.includes(id) && !removeSet.has(id))
|
|
194
|
+
ids.push(id);
|
|
195
|
+
return ids.map((id) => ({ id }));
|
|
196
|
+
}
|
|
197
|
+
function printPolicy(view, app) {
|
|
198
|
+
console.log(`App: ${displayText(app)}`);
|
|
199
|
+
console.log(`Mode: ${displayText(view.mode ?? "unknown")}`);
|
|
200
|
+
if (view.mode === "selected") {
|
|
201
|
+
console.log(`Users: ${view.users.length ? view.users.map((u) => displayText(u.id)).join(", ") : "(none)"}`);
|
|
202
|
+
console.log(`Groups: ${view.groups.length ? view.groups.map((g) => displayText(g.id)).join(", ") : "(none)"}`);
|
|
203
|
+
}
|
|
204
|
+
if (view.policyVersion !== null)
|
|
205
|
+
console.log(`Policy version: ${view.policyVersion}`);
|
|
206
|
+
}
|
|
207
|
+
async function getAction(app, options) {
|
|
208
|
+
const slug = resolveCompany(options.company);
|
|
209
|
+
const token = await ensureCognitoToken();
|
|
210
|
+
const appId = await resolveAppId(token, slug, app);
|
|
211
|
+
const view = await getPolicy(token, slug, appId);
|
|
212
|
+
if (options.json) {
|
|
213
|
+
console.log(safeJson({ company: slug, app: appId, policy: view }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
printPolicy(view, app);
|
|
217
|
+
}
|
|
218
|
+
async function setAction(app, options) {
|
|
219
|
+
// Validate locally first: an unknown mode never reaches the network.
|
|
220
|
+
const requestedMode = options.mode === undefined ? undefined : parseMode(options.mode);
|
|
221
|
+
const editsMembers = [options.addUser, options.removeUser, options.addGroup, options.removeGroup]
|
|
222
|
+
.some((list) => list !== undefined && list.length > 0);
|
|
223
|
+
if (requestedMode === undefined && !editsMembers) {
|
|
224
|
+
await getAction(app, options);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const slug = resolveCompany(options.company);
|
|
228
|
+
const token = await ensureCognitoToken();
|
|
229
|
+
const { id: appId, record } = await resolveApp(token, slug, app);
|
|
230
|
+
const current = await getPolicy(token, slug, appId);
|
|
231
|
+
const mode = requestedMode ?? current.mode;
|
|
232
|
+
if (mode !== "selected" && editsMembers) {
|
|
233
|
+
throw new DeployAccessError("Member lists only apply to selected mode. Add --mode selected, or switch the app to selected first.");
|
|
234
|
+
}
|
|
235
|
+
if (mode === null || !DEPLOY_ACCESS_MODES.includes(mode)) {
|
|
236
|
+
throw new DeployAccessError(`The app is in '${displayText(current.mode ?? "unknown")}' mode. Pass --mode.`);
|
|
237
|
+
}
|
|
238
|
+
// hq-deploy stores the member lists for every mode. Keep the saved lists when
|
|
239
|
+
// switching away from selected so switching back restores them.
|
|
240
|
+
const users = applyEdits(current.users, options.addUser, options.removeUser);
|
|
241
|
+
const groups = applyEdits(current.groups, options.addGroup, options.removeGroup);
|
|
242
|
+
if (mode === "selected" && users.length === 0 && groups.length === 0) {
|
|
243
|
+
throw new DeployAccessError("Selected mode needs at least one member. Pass --add-user <id> or --add-group <id>.");
|
|
244
|
+
}
|
|
245
|
+
// A direct app id skips the list lookup, so read the org-scoped app record
|
|
246
|
+
// before deciding whether this write drops a password. The access-policy row
|
|
247
|
+
// can lag the app's own passwordProtected flag, which hq-deploy toggles
|
|
248
|
+
// separately through PATCH /apps. Fail closed when the state is unknown.
|
|
249
|
+
let approvalRecord = record;
|
|
250
|
+
if (approvalRecord === null) {
|
|
251
|
+
const appResponse = await request(token, slug, `/api/apps/${encodeURIComponent(appId)}`, "GET");
|
|
252
|
+
if (!appResponse.ok)
|
|
253
|
+
throw await errorFor(appResponse, slug, "read the deploy app");
|
|
254
|
+
approvalRecord = await readJson(appResponse);
|
|
255
|
+
if (approvalRecord.id !== appId || typeof approvalRecord.passwordProtected !== "boolean") {
|
|
256
|
+
throw new DeployAccessError("Could not verify the app's password protection. Nothing was changed.");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const companyUid = await resolvePolicyCompanyUid(token, slug, current, approvalRecord);
|
|
260
|
+
await requireApproval(app, current, approvalRecord, mode, options.yes);
|
|
261
|
+
const response = await request(token, slug, `/api/apps/${encodeURIComponent(appId)}/access-policy`, "PUT", {
|
|
262
|
+
mode,
|
|
263
|
+
companyUid,
|
|
264
|
+
users,
|
|
265
|
+
groups,
|
|
266
|
+
});
|
|
267
|
+
if (!response.ok)
|
|
268
|
+
throw await errorFor(response, slug, "update the access policy");
|
|
269
|
+
const updated = toView(await readJson(response));
|
|
270
|
+
if (options.json) {
|
|
271
|
+
console.log(safeJson({ company: slug, app: appId, policy: updated }));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
printPolicy(updated, app);
|
|
275
|
+
}
|
|
276
|
+
async function run(action) {
|
|
277
|
+
try {
|
|
278
|
+
await action();
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error instanceof DeployAccessError) {
|
|
282
|
+
console.error(error.message);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
// Unexpected failure: show the error type and message, never a stack or body.
|
|
286
|
+
const detail = error instanceof Error ? `${error.name}: ${displayText(error.message)}` : "unknown error";
|
|
287
|
+
console.error(`Deploy access command failed (${detail}).`);
|
|
288
|
+
}
|
|
289
|
+
process.exitCode = 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
export function registerDeployAccessCommand(deploy) {
|
|
293
|
+
const access = deploy
|
|
294
|
+
.command("access")
|
|
295
|
+
.description("Show or change who can open a deploy app (company, selected members, or public)")
|
|
296
|
+
.argument("[app]", "app id, subdomain, or exact name")
|
|
297
|
+
.option("--company <slug>", "use this company instead of the active company")
|
|
298
|
+
.option("--mode <mode>", `access mode: ${DEPLOY_ACCESS_MODES.join(" | ")}`)
|
|
299
|
+
.option("--add-user <id>", "add a selected member by person id (repeatable)", collect)
|
|
300
|
+
.option("--remove-user <id>", "remove a selected member by person id (repeatable)", collect)
|
|
301
|
+
.option("--add-group <id>", "add a selected group by id (repeatable)", collect)
|
|
302
|
+
.option("--remove-group <id>", "remove a selected group by id (repeatable)", collect)
|
|
303
|
+
.option("--json", "print the resulting policy as JSON")
|
|
304
|
+
.option("--yes", "approve making the app public or removing its password without a prompt")
|
|
305
|
+
.action(async (app, options, command) => {
|
|
306
|
+
if (!app) {
|
|
307
|
+
command.help({ error: true });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await run(() => setAction(app, options));
|
|
311
|
+
});
|
|
312
|
+
access
|
|
313
|
+
.command("get <app>")
|
|
314
|
+
.description("Show an app's current access mode and selected members")
|
|
315
|
+
.option("--company <slug>", "use this company instead of the active company")
|
|
316
|
+
.option("--json", "print the policy as JSON")
|
|
317
|
+
.action(async (app, _options, command) => {
|
|
318
|
+
// --company/--json may be parsed by the parent `access` command.
|
|
319
|
+
await run(() => getAction(app, command.optsWithGlobals()));
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
//# sourceMappingURL=deploy-access.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq dm send --attach <path>` — upload local files as chat attachments and
|
|
3
|
+
* hand the resulting `attachments[]` to the DM / channel message post.
|
|
4
|
+
*
|
|
5
|
+
* Storage contract (hq-pro files-presign + notify-dm, hq-desktop-app
|
|
6
|
+
* `upload-chat-attachments`): every chat upload lands under
|
|
7
|
+
* `chat/attachments/{dm|chan}/<scopeId>/<file>` in a VAULT SCOPE that the
|
|
8
|
+
* presign call names in `company`:
|
|
9
|
+
* - a 1:1 DM is stored in the SENDER's personal vault, scope = the caller's
|
|
10
|
+
* own `prs_` uid, under `dm/<uidA--uidB>/` (the two participant uids,
|
|
11
|
+
* sorted ascending). files-presign authorizes `put` for either member.
|
|
12
|
+
* - a company channel is stored in that company's vault, scope = the
|
|
13
|
+
* channel's `companyUid`, under `chan/<channelId>/`. A personal channel
|
|
14
|
+
* (no companyUid) uses the caller's personal vault, as the desktop app does.
|
|
15
|
+
* The message row carries only metadata; the server checks on send that each
|
|
16
|
+
* vaultPath belongs to the conversation and that `companyUid` matches the
|
|
17
|
+
* channel's company. Readers use `companyUid` as the presign scope on `get`.
|
|
18
|
+
*
|
|
19
|
+
* Network is injected (`presign`, `put`) so the sequence is unit-testable.
|
|
20
|
+
*/
|
|
21
|
+
import { type PutResponse } from "./library-add.js";
|
|
22
|
+
export declare const MAX_DM_ATTACHMENTS = 5;
|
|
23
|
+
export declare const MAX_DM_ATTACHMENT_BYTES: number;
|
|
24
|
+
/** Metadata the send body carries for one uploaded file. */
|
|
25
|
+
export interface DmAttachmentWire {
|
|
26
|
+
vaultPath: string;
|
|
27
|
+
/** Vault scope the bytes live in (cmp_… or the sender's prs_…). */
|
|
28
|
+
companyUid: string;
|
|
29
|
+
name: string;
|
|
30
|
+
sizeBytes: number;
|
|
31
|
+
contentType: string;
|
|
32
|
+
}
|
|
33
|
+
/** One validated local file, before upload. */
|
|
34
|
+
export interface LocalAttachment {
|
|
35
|
+
path: string;
|
|
36
|
+
name: string;
|
|
37
|
+
sizeBytes: number;
|
|
38
|
+
contentType: string;
|
|
39
|
+
}
|
|
40
|
+
export type ChatAttachmentScope = {
|
|
41
|
+
scope: "dm";
|
|
42
|
+
uids: [string, string];
|
|
43
|
+
} | {
|
|
44
|
+
scope: "chan";
|
|
45
|
+
channelId: string;
|
|
46
|
+
};
|
|
47
|
+
/** Reduce a local file name to one safe path component (mirrors the desktop app). */
|
|
48
|
+
export declare function sanitizeAttachmentName(raw: string): string;
|
|
49
|
+
/** `yyyymmddThhmmss` in UTC — a sortable per-send prefix that keeps same-named files apart. */
|
|
50
|
+
export declare function attachmentTimestamp(now: Date): string;
|
|
51
|
+
/** The server's DM pair key is `[a,b].sort().join("#")` with `#` rendered as `--` in keys. */
|
|
52
|
+
export declare function dmAttachmentScopeId(a: string, b: string): string;
|
|
53
|
+
/** Build the scope-relative vault key for one upload. Pure. */
|
|
54
|
+
export declare function buildChatAttachmentKey(args: {
|
|
55
|
+
target: ChatAttachmentScope;
|
|
56
|
+
name: string;
|
|
57
|
+
now: Date;
|
|
58
|
+
}): string;
|
|
59
|
+
/**
|
|
60
|
+
* Check every `--attach` path before anything touches the network: it must
|
|
61
|
+
* exist, be a regular file, and fit the server cap; at most five per message.
|
|
62
|
+
* Throws an Error that names the offending file.
|
|
63
|
+
*/
|
|
64
|
+
export declare function validateAttachmentPaths(paths: readonly string[], stat?: (p: string) => {
|
|
65
|
+
isFile(): boolean;
|
|
66
|
+
size: number;
|
|
67
|
+
}): LocalAttachment[];
|
|
68
|
+
export interface AttachmentUploadDeps {
|
|
69
|
+
/** POST /v1/files/presign with `{ company, keys }`. */
|
|
70
|
+
presign: (token: string, body: Record<string, unknown>) => Promise<Response>;
|
|
71
|
+
/** PUT one file's bytes to a presigned URL with the returned headers. */
|
|
72
|
+
put: (file: LocalAttachment, url: string, headers: Record<string, string>) => Promise<PutResponse>;
|
|
73
|
+
}
|
|
74
|
+
export declare const defaultAttachmentUploadDeps: AttachmentUploadDeps;
|
|
75
|
+
/**
|
|
76
|
+
* Presign-put every file into `scopeUid`, then return the `attachments[]`
|
|
77
|
+
* entries for the send body. One presign request covers all files (hq-pro
|
|
78
|
+
* rate-limits presign per user). Any refusal aborts before the message is
|
|
79
|
+
* sent, so a DM never references bytes that are not there.
|
|
80
|
+
*/
|
|
81
|
+
export declare function uploadDmAttachments(args: {
|
|
82
|
+
token: string;
|
|
83
|
+
scopeUid: string;
|
|
84
|
+
target: ChatAttachmentScope;
|
|
85
|
+
files: LocalAttachment[];
|
|
86
|
+
now?: Date;
|
|
87
|
+
deps?: AttachmentUploadDeps;
|
|
88
|
+
}): Promise<DmAttachmentWire[]>;
|
|
89
|
+
/**
|
|
90
|
+
* The vault scope for a channel post: the channel's company when it has one,
|
|
91
|
+
* else the caller's own personal vault. Mirrors the desktop app's
|
|
92
|
+
* `attachmentVaultScopeUid` (no "first company" fallback).
|
|
93
|
+
*/
|
|
94
|
+
export declare function attachmentScopeForChannel(channel: {
|
|
95
|
+
companyUid?: string;
|
|
96
|
+
scope?: string;
|
|
97
|
+
}, callerUid: string): string;
|
|
98
|
+
/**
|
|
99
|
+
* The caller's own vault scope for a 1:1 DM attachment. Only a person
|
|
100
|
+
* (`prs_`) has a personal vault; files-presign refuses any other scope uid, so
|
|
101
|
+
* an agent sender (agt_) is refused here before the upload is attempted.
|
|
102
|
+
*/
|
|
103
|
+
export declare function personalScopeForCaller(callerUid: string | undefined): string;
|
|
104
|
+
/** Render the confirmation lines listing where each attachment landed. */
|
|
105
|
+
export declare function formatUploadedAttachments(files: readonly DmAttachmentWire[]): string;
|
|
106
|
+
//# sourceMappingURL=dm-attach.d.ts.map
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq dm send --attach <path>` — upload local files as chat attachments and
|
|
3
|
+
* hand the resulting `attachments[]` to the DM / channel message post.
|
|
4
|
+
*
|
|
5
|
+
* Storage contract (hq-pro files-presign + notify-dm, hq-desktop-app
|
|
6
|
+
* `upload-chat-attachments`): every chat upload lands under
|
|
7
|
+
* `chat/attachments/{dm|chan}/<scopeId>/<file>` in a VAULT SCOPE that the
|
|
8
|
+
* presign call names in `company`:
|
|
9
|
+
* - a 1:1 DM is stored in the SENDER's personal vault, scope = the caller's
|
|
10
|
+
* own `prs_` uid, under `dm/<uidA--uidB>/` (the two participant uids,
|
|
11
|
+
* sorted ascending). files-presign authorizes `put` for either member.
|
|
12
|
+
* - a company channel is stored in that company's vault, scope = the
|
|
13
|
+
* channel's `companyUid`, under `chan/<channelId>/`. A personal channel
|
|
14
|
+
* (no companyUid) uses the caller's personal vault, as the desktop app does.
|
|
15
|
+
* The message row carries only metadata; the server checks on send that each
|
|
16
|
+
* vaultPath belongs to the conversation and that `companyUid` matches the
|
|
17
|
+
* channel's company. Readers use `companyUid` as the presign scope on `get`.
|
|
18
|
+
*
|
|
19
|
+
* Network is injected (`presign`, `put`) so the sequence is unit-testable.
|
|
20
|
+
*/
|
|
21
|
+
import { createReadStream, statSync } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
24
|
+
import { contentTypeFor, streamPut } from "./library-add.js";
|
|
25
|
+
export const MAX_DM_ATTACHMENTS = 5;
|
|
26
|
+
export const MAX_DM_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
27
|
+
/** Reduce a local file name to one safe path component (mirrors the desktop app). */
|
|
28
|
+
export function sanitizeAttachmentName(raw) {
|
|
29
|
+
const base = raw.trim().split(/[/\\]/).pop() ?? "file";
|
|
30
|
+
const cleaned = base.replace(/[^\w.\- ()[\]]+/g, "_").replace(/^\.+/, "");
|
|
31
|
+
return cleaned.slice(0, 180) || "file";
|
|
32
|
+
}
|
|
33
|
+
/** `yyyymmddThhmmss` in UTC — a sortable per-send prefix that keeps same-named files apart. */
|
|
34
|
+
export function attachmentTimestamp(now) {
|
|
35
|
+
return now.toISOString().replace(/[-:]/g, "").slice(0, 15);
|
|
36
|
+
}
|
|
37
|
+
/** The server's DM pair key is `[a,b].sort().join("#")` with `#` rendered as `--` in keys. */
|
|
38
|
+
export function dmAttachmentScopeId(a, b) {
|
|
39
|
+
return [a, b].sort().join("--");
|
|
40
|
+
}
|
|
41
|
+
/** Build the scope-relative vault key for one upload. Pure. */
|
|
42
|
+
export function buildChatAttachmentKey(args) {
|
|
43
|
+
const scopeId = args.target.scope === "dm"
|
|
44
|
+
? dmAttachmentScopeId(args.target.uids[0], args.target.uids[1])
|
|
45
|
+
: args.target.channelId;
|
|
46
|
+
if (!scopeId || scopeId.includes("/")) {
|
|
47
|
+
throw new Error("Cannot build an attachment key: the conversation id is missing or malformed.");
|
|
48
|
+
}
|
|
49
|
+
return `chat/attachments/${args.target.scope}/${scopeId}/${attachmentTimestamp(args.now)}-${sanitizeAttachmentName(args.name)}`;
|
|
50
|
+
}
|
|
51
|
+
function formatBytes(n) {
|
|
52
|
+
if (n >= 1024 * 1024)
|
|
53
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
54
|
+
if (n >= 1024)
|
|
55
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
56
|
+
return `${n} B`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Check every `--attach` path before anything touches the network: it must
|
|
60
|
+
* exist, be a regular file, and fit the server cap; at most five per message.
|
|
61
|
+
* Throws an Error that names the offending file.
|
|
62
|
+
*/
|
|
63
|
+
export function validateAttachmentPaths(paths, stat = statSync) {
|
|
64
|
+
if (paths.length > MAX_DM_ATTACHMENTS) {
|
|
65
|
+
throw new Error(`Too many attachments: ${paths.length} given, at most ${MAX_DM_ATTACHMENTS} per message.`);
|
|
66
|
+
}
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const raw of paths) {
|
|
69
|
+
const p = raw.trim();
|
|
70
|
+
if (!p)
|
|
71
|
+
throw new Error("--attach requires a non-empty file path.");
|
|
72
|
+
let st;
|
|
73
|
+
try {
|
|
74
|
+
st = stat(p);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error(`Attachment not found: ${p}`);
|
|
78
|
+
}
|
|
79
|
+
if (!st.isFile()) {
|
|
80
|
+
throw new Error(`Attachment is not a regular file: ${p}`);
|
|
81
|
+
}
|
|
82
|
+
if (st.size > MAX_DM_ATTACHMENT_BYTES) {
|
|
83
|
+
throw new Error(`Attachment too large: ${p} is ${formatBytes(st.size)}; the limit is 25 MB.`);
|
|
84
|
+
}
|
|
85
|
+
const name = sanitizeAttachmentName(path.basename(p));
|
|
86
|
+
out.push({ path: p, name, sizeBytes: st.size, contentType: contentTypeFor(name) });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
export const defaultAttachmentUploadDeps = {
|
|
91
|
+
presign: (token, body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body }),
|
|
92
|
+
put: (file, url, headers) => streamPut(url, headers, createReadStream(file.path), file.sizeBytes),
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Presign-put every file into `scopeUid`, then return the `attachments[]`
|
|
96
|
+
* entries for the send body. One presign request covers all files (hq-pro
|
|
97
|
+
* rate-limits presign per user). Any refusal aborts before the message is
|
|
98
|
+
* sent, so a DM never references bytes that are not there.
|
|
99
|
+
*/
|
|
100
|
+
export async function uploadDmAttachments(args) {
|
|
101
|
+
const deps = args.deps ?? defaultAttachmentUploadDeps;
|
|
102
|
+
const now = args.now ?? new Date();
|
|
103
|
+
if (args.files.length === 0)
|
|
104
|
+
return [];
|
|
105
|
+
const planned = args.files.map((file) => ({
|
|
106
|
+
file,
|
|
107
|
+
key: buildChatAttachmentKey({ target: args.target, name: file.name, now }),
|
|
108
|
+
}));
|
|
109
|
+
const res = await deps.presign(args.token, {
|
|
110
|
+
company: args.scopeUid,
|
|
111
|
+
keys: planned.map((p) => ({ key: p.key, op: "put", contentType: p.file.contentType })),
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const err = (await res.json().catch(() => ({})));
|
|
115
|
+
const message = typeof err.error === "string" ? err.error : typeof err.message === "string" ? err.message : res.statusText;
|
|
116
|
+
throw new Error(`Could not prepare the attachment upload (HTTP ${res.status}): ${message}`);
|
|
117
|
+
}
|
|
118
|
+
const data = (await res.json().catch(() => ({})));
|
|
119
|
+
const rows = new Map();
|
|
120
|
+
for (const row of data.results ?? []) {
|
|
121
|
+
if (row && typeof row === "object" && typeof row.key === "string") {
|
|
122
|
+
rows.set(row.key, row);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const p of planned) {
|
|
127
|
+
const row = rows.get(p.key);
|
|
128
|
+
if (!row || typeof row.url !== "string") {
|
|
129
|
+
const reason = typeof row?.error === "string" ? row.error : "the vault refused the upload";
|
|
130
|
+
throw new Error(`Attachment refused: ${p.file.path} — ${reason}`);
|
|
131
|
+
}
|
|
132
|
+
const headers = {};
|
|
133
|
+
if (row.headers && typeof row.headers === "object") {
|
|
134
|
+
for (const [k, v] of Object.entries(row.headers)) {
|
|
135
|
+
if (typeof v === "string")
|
|
136
|
+
headers[k] = v;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const put = await deps.put(p.file, row.url, headers);
|
|
140
|
+
if (put.status < 200 || put.status >= 300) {
|
|
141
|
+
throw new Error(`Upload failed for ${p.file.path} (HTTP ${put.status}). The message was not sent.`);
|
|
142
|
+
}
|
|
143
|
+
out.push({
|
|
144
|
+
vaultPath: p.key,
|
|
145
|
+
companyUid: args.scopeUid,
|
|
146
|
+
name: p.file.name,
|
|
147
|
+
sizeBytes: p.file.sizeBytes,
|
|
148
|
+
contentType: p.file.contentType,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The vault scope for a channel post: the channel's company when it has one,
|
|
155
|
+
* else the caller's own personal vault. Mirrors the desktop app's
|
|
156
|
+
* `attachmentVaultScopeUid` (no "first company" fallback).
|
|
157
|
+
*/
|
|
158
|
+
export function attachmentScopeForChannel(channel, callerUid) {
|
|
159
|
+
const fromChannel = channel.companyUid?.trim() ?? "";
|
|
160
|
+
if (fromChannel && !fromChannel.startsWith("prs_"))
|
|
161
|
+
return fromChannel;
|
|
162
|
+
return callerUid;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The caller's own vault scope for a 1:1 DM attachment. Only a person
|
|
166
|
+
* (`prs_`) has a personal vault; files-presign refuses any other scope uid, so
|
|
167
|
+
* an agent sender (agt_) is refused here before the upload is attempted.
|
|
168
|
+
*/
|
|
169
|
+
export function personalScopeForCaller(callerUid) {
|
|
170
|
+
if (!callerUid) {
|
|
171
|
+
throw new Error("Could not read your person uid from the session token — run `hq login` and try again.");
|
|
172
|
+
}
|
|
173
|
+
if (!callerUid.startsWith("prs_")) {
|
|
174
|
+
throw new Error(`--attach on a 1:1 DM needs a personal vault, which only a person sender has (you are ${callerUid}). Post the file to a company channel instead.`);
|
|
175
|
+
}
|
|
176
|
+
return callerUid;
|
|
177
|
+
}
|
|
178
|
+
/** Render the confirmation lines listing where each attachment landed. */
|
|
179
|
+
export function formatUploadedAttachments(files) {
|
|
180
|
+
return files
|
|
181
|
+
.map((f) => ` [file] ${f.name} (${formatBytes(f.sizeBytes)})\n vault: ${f.vaultPath}`)
|
|
182
|
+
.join("\n");
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=dm-attach.js.map
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { type DmAttachmentWire } from "./dm-attach.js";
|
|
2
3
|
export interface DmRecipient {
|
|
3
4
|
toEmail?: string;
|
|
4
5
|
toPersonUid?: string;
|
|
@@ -48,6 +49,8 @@ export interface ChannelSummary {
|
|
|
48
49
|
personUid: string;
|
|
49
50
|
displayName?: string;
|
|
50
51
|
}[];
|
|
52
|
+
/** Set on company-scoped channels; the vault scope their attachments live in. */
|
|
53
|
+
companyUid?: string;
|
|
51
54
|
}
|
|
52
55
|
/**
|
|
53
56
|
* Find the caller's channel(s) whose name matches `name`, by slug or
|
|
@@ -57,6 +60,28 @@ export interface ChannelSummary {
|
|
|
57
60
|
* unit-testable.
|
|
58
61
|
*/
|
|
59
62
|
export declare function matchChannelsByName(channels: ChannelSummary[], name: string): ChannelSummary[];
|
|
63
|
+
/** One row from GET /v1/notify/contacts (persons the caller can DM). */
|
|
64
|
+
export interface DmContact {
|
|
65
|
+
personUid?: string;
|
|
66
|
+
email?: string;
|
|
67
|
+
displayName?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Find the single contact whose display name (case-insensitive, or by slug)
|
|
71
|
+
* equals a bare recipient name. Email local parts are not matched: they are not
|
|
72
|
+
* the name the user sees, and matching them would divert channel posts. A bare word like `setup` used
|
|
73
|
+
* to go straight to a channel lookup, so `hq dm send setup` landed in a channel
|
|
74
|
+
* whose slug was `setup` even when a person by that name existed (feedback
|
|
75
|
+
* eba06f59). Returns null when nothing or more than one contact matches. Pure.
|
|
76
|
+
*/
|
|
77
|
+
export declare function matchContactByName(contacts: DmContact[], name: string): DmContact | null;
|
|
78
|
+
/**
|
|
79
|
+
* The confirmation line for a channel send. When the typed target is not the
|
|
80
|
+
* channel's own display name (for example `hq dm setup` matching a channel
|
|
81
|
+
* whose slug is `setup` but whose display name is `welcome`), say what it
|
|
82
|
+
* resolved to: the typed text, the channel's scope, and its id. Pure.
|
|
83
|
+
*/
|
|
84
|
+
export declare function formatChannelPostedLine(channel: ChannelSummary, typed: string): string;
|
|
60
85
|
/** Server-side cap on one post's mentions (hq-pro MAX_CHANNEL_MENTIONS). */
|
|
61
86
|
export declare const MAX_CHANNEL_MENTIONS = 25;
|
|
62
87
|
/**
|
|
@@ -142,6 +167,8 @@ export interface DmSendBody {
|
|
|
142
167
|
details?: string;
|
|
143
168
|
deliverAt?: string;
|
|
144
169
|
audience?: DmAudience;
|
|
170
|
+
/** Uploaded chat attachments (`--attach`); the server accepts up to 5. */
|
|
171
|
+
attachments?: DmAttachmentWire[];
|
|
145
172
|
}
|
|
146
173
|
/**
|
|
147
174
|
* Build the POST /v1/notify/dm request body from CLI inputs. Pure (no I/O,
|
|
@@ -159,7 +186,17 @@ export declare function buildDmBody(args: {
|
|
|
159
186
|
inDelay?: string;
|
|
160
187
|
now: number;
|
|
161
188
|
audience?: string;
|
|
189
|
+
attachments?: DmAttachmentWire[];
|
|
162
190
|
}): DmSendBody;
|
|
191
|
+
/**
|
|
192
|
+
* The other participant's uid for a 1:1 DM attachment key. A `prs_`/`agt_`
|
|
193
|
+
* recipient is already a uid; an email is looked up in the caller's contacts
|
|
194
|
+
* (GET /v1/notify/contacts), the same route `dm forward --from <email>` uses.
|
|
195
|
+
* There is no separate "person by email" route on the vault API, and a DM to
|
|
196
|
+
* someone outside your contacts is a connection request anyway, which the
|
|
197
|
+
* server holds without delivering files.
|
|
198
|
+
*/
|
|
199
|
+
export declare function resolveDmPeerUid(token: string, recipient: string): Promise<string>;
|
|
163
200
|
/** A pending incoming connection request, as returned by the backend. */
|
|
164
201
|
export interface ConnectionRequest {
|
|
165
202
|
pairKey: string;
|