insta 0.0.32 → 0.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/commands/feedback.js +232 -0
- package/dist/commands/services.js +15 -11
- package/dist/commands/storage.js +173 -0
- package/dist/index.js +39 -1
- package/dist/redact.js +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -184,6 +184,7 @@ build never reaches a production installer.
|
|
|
184
184
|
| `insta approvals` | `list` · `approve` · `deny` |
|
|
185
185
|
| `insta policy` | `get` · `set <action> <decision>` |
|
|
186
186
|
| `insta observe` | `install` · `uninstall` · `report` · `sync` — local credential audit |
|
|
187
|
+
| `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out |
|
|
187
188
|
| `insta upgrade` · `autoupdate` | Update the CLI; show or set auto-update |
|
|
188
189
|
|
|
189
190
|
## Configuration
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// `insta feedback` — report an InstaCloud-side hurdle to the InstaCloud team.
|
|
2
|
+
//
|
|
3
|
+
// Scope rule (also stated in the skill): this is for problems in OUR toolkit — the CLI, the MCP
|
|
4
|
+
// server, the platform, the skills, the docs. Never for problems in the app the user is building.
|
|
5
|
+
//
|
|
6
|
+
// The backend is InstaCloud dogfooding itself: the "InstaCloud Agent Feedback" project runs the
|
|
7
|
+
// ingest service (InsForge/insta-feedback repo) on a postgres + compute pair. It is NOT the
|
|
8
|
+
// control-plane API on purpose — feedback must work logged-out, unlinked, and from insta-oss,
|
|
9
|
+
// and a control-plane outage is exactly when we most want reports to still arrive.
|
|
10
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import * as clack from '@clack/prompts';
|
|
13
|
+
import { readGlobal, readProject } from '../config.js';
|
|
14
|
+
import { envForApiUrl } from '../env.js';
|
|
15
|
+
import { info, printJson } from '../util.js';
|
|
16
|
+
import { clean } from '../redact.js';
|
|
17
|
+
export const TYPES = ['bug', 'feature-request', 'friction', 'other'];
|
|
18
|
+
export const COMPONENTS = ['cli', 'mcp', 'platform', 'skills', 'docs', 'other'];
|
|
19
|
+
export const SEVERITIES = ['blocker', 'major', 'minor'];
|
|
20
|
+
// Field caps mirror the ingest service's LIMITS (insta-feedback src/app.ts) — the server
|
|
21
|
+
// truncates again, so a mismatch degrades gracefully instead of rejecting.
|
|
22
|
+
export const LIMITS = {
|
|
23
|
+
title: 200,
|
|
24
|
+
detail: 4000,
|
|
25
|
+
area: 100,
|
|
26
|
+
command: 500,
|
|
27
|
+
error: 2000,
|
|
28
|
+
expected: 1000,
|
|
29
|
+
workaround: 1000,
|
|
30
|
+
doc: 300,
|
|
31
|
+
};
|
|
32
|
+
// Hardcoded in source, not injected at build time: a build-time credential silently no-ops in
|
|
33
|
+
// local/tsx and fork builds, and feedback would appear to work while reports vanish. The token is
|
|
34
|
+
// public by design (it ships in this file); it only deflects drive-by scanners — real abuse
|
|
35
|
+
// control is server-side (per-IP rate limit + weekly dedup). Env overrides are for tests and
|
|
36
|
+
// emergency rotation.
|
|
37
|
+
const FEEDBACK_ENDPOINT = process.env.INSTA_FEEDBACK_URL ||
|
|
38
|
+
'https://insta-main-api-cdad9b6c.compute.instacloud.com/v1/feedback';
|
|
39
|
+
const FEEDBACK_INGEST_TOKEN = process.env.INSTA_FEEDBACK_TOKEN || 'insta-feedback-public-v1';
|
|
40
|
+
const FEEDBACK_TIMEOUT_MS = 10_000;
|
|
41
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
42
|
+
function resolveCliVersion() {
|
|
43
|
+
// Same resolution as index.ts: the standalone binary bakes INSTA_CLI_VERSION via --define;
|
|
44
|
+
// npm/node reads the installed package.json next to dist/.
|
|
45
|
+
if (process.env.INSTA_CLI_VERSION)
|
|
46
|
+
return process.env.INSTA_CLI_VERSION;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return '0.0.0';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function requireEnum(value, allowed, flag) {
|
|
55
|
+
if (!allowed.includes(value)) {
|
|
56
|
+
throw new Error(`${flag} must be one of: ${allowed.join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
async function promptMissing(opts) {
|
|
61
|
+
clack.intro('insta feedback — report an InstaCloud-side hurdle');
|
|
62
|
+
if (!opts.type) {
|
|
63
|
+
const answer = await clack.select({
|
|
64
|
+
message: 'What kind of hurdle did you hit?',
|
|
65
|
+
options: [
|
|
66
|
+
{ value: 'bug', label: 'bug — something InstaCloud should do, but does not' },
|
|
67
|
+
{ value: 'feature-request', label: 'feature-request — something InstaCloud does not support yet' },
|
|
68
|
+
{ value: 'friction', label: 'friction — works, but confusing or awkward' },
|
|
69
|
+
{ value: 'other', label: 'other' },
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
if (clack.isCancel(answer))
|
|
73
|
+
process.exit(0);
|
|
74
|
+
opts.type = answer;
|
|
75
|
+
}
|
|
76
|
+
if (!opts.component) {
|
|
77
|
+
const answer = await clack.select({
|
|
78
|
+
message: 'Where in the InstaCloud toolkit is the issue?',
|
|
79
|
+
options: COMPONENTS.map((c) => ({ value: c, label: c })),
|
|
80
|
+
});
|
|
81
|
+
if (clack.isCancel(answer))
|
|
82
|
+
process.exit(0);
|
|
83
|
+
opts.component = answer;
|
|
84
|
+
}
|
|
85
|
+
if (!opts.title) {
|
|
86
|
+
const answer = await clack.text({
|
|
87
|
+
message: 'One-line summary:',
|
|
88
|
+
validate: (v) => (v.trim() ? undefined : 'required'),
|
|
89
|
+
});
|
|
90
|
+
if (clack.isCancel(answer))
|
|
91
|
+
process.exit(0);
|
|
92
|
+
opts.title = answer.trim();
|
|
93
|
+
}
|
|
94
|
+
if (!opts.detail && !opts.file) {
|
|
95
|
+
const answer = await clack.text({
|
|
96
|
+
message: 'What happened, and what did you expect?',
|
|
97
|
+
validate: (v) => (v.trim() ? undefined : 'required'),
|
|
98
|
+
});
|
|
99
|
+
if (clack.isCancel(answer))
|
|
100
|
+
process.exit(0);
|
|
101
|
+
opts.detail = answer.trim();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Pure payload assembly (unit-tested): validation, redaction, caps, and ambient context. */
|
|
105
|
+
export async function buildPayload(opts, ctx) {
|
|
106
|
+
const type = requireEnum(opts.type ?? '', TYPES, '--type');
|
|
107
|
+
const component = requireEnum(opts.component ?? '', COMPONENTS, '--component');
|
|
108
|
+
const severity = opts.severity ? requireEnum(opts.severity, SEVERITIES, '--severity') : 'minor';
|
|
109
|
+
let detail = opts.detail;
|
|
110
|
+
if (!detail && opts.file) {
|
|
111
|
+
try {
|
|
112
|
+
// detail is capped at 4000 chars — a file far beyond that is a mistake (wrong path, a log
|
|
113
|
+
// archive, a binary), so refuse before allocating it rather than truncating garbage.
|
|
114
|
+
// Regular files only: a FIFO/device node (e.g. /dev/zero) stats as size 0, sails past the
|
|
115
|
+
// byte ceiling, and then readFileSync reads unbounded.
|
|
116
|
+
const stat = statSync(opts.file);
|
|
117
|
+
if (!stat.isFile())
|
|
118
|
+
throw new Error(`--file ${opts.file} is not a regular file`);
|
|
119
|
+
const size = stat.size;
|
|
120
|
+
if (size > MAX_FILE_BYTES) {
|
|
121
|
+
throw new Error(`--file ${opts.file} is ${size} bytes — max ${MAX_FILE_BYTES} (detail is capped at ${LIMITS.detail} chars; trim the file first)`);
|
|
122
|
+
}
|
|
123
|
+
detail = readFileSync(opts.file, 'utf8');
|
|
124
|
+
if (detail.includes('\0'))
|
|
125
|
+
throw new Error(`--file ${opts.file} looks binary — feedback detail must be text`);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
129
|
+
throw new Error(msg.startsWith('--file') ? msg : `--file ${opts.file}: ${msg}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const title = clean(opts.title, LIMITS.title);
|
|
133
|
+
if (!title)
|
|
134
|
+
throw new Error('--title is required (one-line summary, ≤200 chars)');
|
|
135
|
+
const cleanedDetail = clean(detail, LIMITS.detail);
|
|
136
|
+
if (!cleanedDetail)
|
|
137
|
+
throw new Error('--detail (or --file <path>) is required: what happened vs what you expected');
|
|
138
|
+
const project = await readProject();
|
|
139
|
+
const { apiUrl } = await readGlobal();
|
|
140
|
+
// envForApiUrl → null means a custom host: insta-oss or a preview deployment (see env.ts).
|
|
141
|
+
const target = envForApiUrl(apiUrl) ? 'cloud' : 'oss';
|
|
142
|
+
return {
|
|
143
|
+
type,
|
|
144
|
+
component,
|
|
145
|
+
severity,
|
|
146
|
+
title,
|
|
147
|
+
detail: cleanedDetail,
|
|
148
|
+
area: clean(opts.area, LIMITS.area),
|
|
149
|
+
command: clean(opts.command, LIMITS.command),
|
|
150
|
+
error: clean(opts.error, LIMITS.error),
|
|
151
|
+
expected: clean(opts.expected, LIMITS.expected),
|
|
152
|
+
workaround: clean(opts.workaround, LIMITS.workaround),
|
|
153
|
+
doc_ref: clean(opts.doc, LIMITS.doc),
|
|
154
|
+
source: 'cli',
|
|
155
|
+
target,
|
|
156
|
+
client_version: ctx.cliVersion,
|
|
157
|
+
node_version: process.version,
|
|
158
|
+
os: `${os.platform()} ${os.release()}`,
|
|
159
|
+
project_id: project?.projectId,
|
|
160
|
+
org_id: project?.orgId,
|
|
161
|
+
branch: project?.branch,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** One POST, 10s timeout, zero retries — feedback is a side quest and must never hang the CLI.
|
|
165
|
+
* Transport and server failures come back as a result, not an exception: the caller downgrades
|
|
166
|
+
* them to a warning so a broken feedback backend can't fail the user's actual task. */
|
|
167
|
+
export async function submit(payload, fetchImpl) {
|
|
168
|
+
let res;
|
|
169
|
+
try {
|
|
170
|
+
res = await fetchImpl(FEEDBACK_ENDPOINT, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
Authorization: `Bearer ${FEEDBACK_INGEST_TOKEN}`,
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify(payload),
|
|
177
|
+
signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
const timedOut = e instanceof Error && e.name === 'TimeoutError';
|
|
182
|
+
return { status: 'error', error: timedOut ? `timed out after ${FEEDBACK_TIMEOUT_MS / 1000}s` : `network error: ${e instanceof Error ? e.message : String(e)}` };
|
|
183
|
+
}
|
|
184
|
+
let body = {};
|
|
185
|
+
try {
|
|
186
|
+
body = await res.json();
|
|
187
|
+
}
|
|
188
|
+
catch { /* non-JSON body — fall through to status handling */ }
|
|
189
|
+
if (!res.ok)
|
|
190
|
+
return { status: 'error', error: body?.error ?? `HTTP ${res.status}` };
|
|
191
|
+
return { status: body?.status === 'duplicate' ? 'duplicate' : 'received', id: body?.id ?? null };
|
|
192
|
+
}
|
|
193
|
+
export async function feedback(opts, deps = {}) {
|
|
194
|
+
const interactive = deps.interactive ?? (!opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY);
|
|
195
|
+
const missingRequired = !opts.type || !opts.component || !opts.title || (!opts.detail && !opts.file);
|
|
196
|
+
if (missingRequired && interactive)
|
|
197
|
+
await promptMissing(opts);
|
|
198
|
+
// Bad/missing input exits 1 either way — an agent CAN fix its flags, so the error must be loud
|
|
199
|
+
// and self-teaching (it lists the exact enum values). But it must arrive on the channel the
|
|
200
|
+
// caller chose: --json gets a machine-readable object on stdout (uniform with the success and
|
|
201
|
+
// transport-failure shapes) instead of guard()'s plaintext stderr line.
|
|
202
|
+
let payload;
|
|
203
|
+
try {
|
|
204
|
+
payload = await buildPayload(opts, { cliVersion: deps.cliVersion ?? resolveCliVersion() });
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
if (!opts.json)
|
|
208
|
+
throw e;
|
|
209
|
+
printJson({ status: 'error', submitted: false, error: e instanceof Error ? e.message : String(e) });
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const result = await submit(payload, deps.fetchImpl ?? fetch);
|
|
214
|
+
if (result.status === 'error') {
|
|
215
|
+
// Deliberate exit 0: an agent CANNOT fix a down/rate-limited backend, and feedback must never
|
|
216
|
+
// fail or distract from the task the user actually asked for. Do not retry.
|
|
217
|
+
if (opts.json)
|
|
218
|
+
return printJson({ status: 'error', submitted: false, error: result.error });
|
|
219
|
+
process.stderr.write(`warning: feedback not submitted (${result.error}) — continue with your task, do not retry\n`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (opts.json)
|
|
223
|
+
return printJson({ status: result.status, id: result.id });
|
|
224
|
+
if (result.status === 'duplicate') {
|
|
225
|
+
info(`already reported this week — bumped its count instead (id: ${result.id})`);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
info(`feedback submitted (id: ${result.id}) — thank you!`);
|
|
229
|
+
}
|
|
230
|
+
info('PII (emails, tokens, keys, home paths) was redacted before sending.');
|
|
231
|
+
}
|
|
232
|
+
//# sourceMappingURL=feedback.js.map
|
|
@@ -53,20 +53,24 @@ export function resolveServiceId(services, type, name) {
|
|
|
53
53
|
throw new Error(`service not found: ${type} ${name}`);
|
|
54
54
|
return svc.id;
|
|
55
55
|
}
|
|
56
|
-
// Resolve
|
|
57
|
-
export function
|
|
58
|
-
const
|
|
56
|
+
// Resolve one service of a type: by name, or the sole one of that type when name is omitted.
|
|
57
|
+
export function resolveSoleService(services, type, name) {
|
|
58
|
+
const of = services.filter((s) => s.type === type);
|
|
59
59
|
if (name) {
|
|
60
|
-
const svc =
|
|
60
|
+
const svc = of.find((s) => s.name === name);
|
|
61
61
|
if (!svc)
|
|
62
|
-
throw new Error(
|
|
63
|
-
return svc
|
|
62
|
+
throw new Error(`${type} service not found: ${name}`);
|
|
63
|
+
return svc;
|
|
64
64
|
}
|
|
65
|
-
if (
|
|
66
|
-
throw new Error(
|
|
67
|
-
if (
|
|
68
|
-
throw new Error(`multiple
|
|
69
|
-
return
|
|
65
|
+
if (of.length === 0)
|
|
66
|
+
throw new Error(`no ${type} service in this project (add one with \`insta services add ${type} <name>\`)`);
|
|
67
|
+
if (of.length > 1)
|
|
68
|
+
throw new Error(`multiple ${type} services — specify one: ${of.map((s) => s.name).join(', ')}`);
|
|
69
|
+
return of[0];
|
|
70
|
+
}
|
|
71
|
+
// Resolve a compute service id: by name, or the sole compute service when name is omitted.
|
|
72
|
+
export function resolveComputeServiceId(services, name) {
|
|
73
|
+
return resolveSoleService(services, 'compute', name).id;
|
|
70
74
|
}
|
|
71
75
|
// Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
|
|
72
76
|
// mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// `insta storage` — browse, download, and delete the objects in a storage service's bucket.
|
|
2
|
+
import { chmod, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { createWriteStream, rmSync } from 'node:fs';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
8
|
+
import { info, printJson, handleApproval } from '../util.js';
|
|
9
|
+
import { q, resolveSoleService } from './services.js';
|
|
10
|
+
import { fmtBytes } from './db.js'; // the repo's tested bytes formatter — don't grow a third copy
|
|
11
|
+
function qs(params) {
|
|
12
|
+
const u = new URLSearchParams();
|
|
13
|
+
for (const [k, v] of Object.entries(params))
|
|
14
|
+
if (v !== undefined && v !== '')
|
|
15
|
+
u.set(k, String(v));
|
|
16
|
+
const s = u.toString();
|
|
17
|
+
return s ? `?${s}` : '';
|
|
18
|
+
}
|
|
19
|
+
// Page size the listing route accepts. Junk must fail here, not travel as `limit=NaN`.
|
|
20
|
+
export function parseObjectLimit(raw) {
|
|
21
|
+
const n = Number(raw);
|
|
22
|
+
if (!Number.isInteger(n) || n < 1 || n > 1000)
|
|
23
|
+
throw new Error(`--limit must be an integer 1..1000, got: ${raw}`);
|
|
24
|
+
return n;
|
|
25
|
+
}
|
|
26
|
+
// pure: platform path for the objects collection — GET lists it, DELETE removes one `key`.
|
|
27
|
+
export function objectsPath(projectId, serviceId, params) {
|
|
28
|
+
const { limit, ...rest } = params;
|
|
29
|
+
return `/projects/${projectId}/services/${serviceId}/objects${qs({ ...rest, limit: limit === undefined ? undefined : String(limit) })}`;
|
|
30
|
+
}
|
|
31
|
+
// pure: the presign route — a static subpath, so keys containing `/` stay in the query.
|
|
32
|
+
export function objectDownloadPath(projectId, serviceId, params) {
|
|
33
|
+
return `/projects/${projectId}/services/${serviceId}/objects/download${qs(params)}`;
|
|
34
|
+
}
|
|
35
|
+
// pure: one `storage list` row, size-first so the columns line up over variable-length keys.
|
|
36
|
+
export function objectListLine(o) {
|
|
37
|
+
const size = typeof o.size === 'number' ? fmtBytes(o.size) : '—';
|
|
38
|
+
return `${size.padStart(10)} ${(o.lastModified ?? '—').padEnd(24)} ${o.key}`;
|
|
39
|
+
}
|
|
40
|
+
// Resolve the branch's storage service (named, or the sole one) — its bucket is what we browse.
|
|
41
|
+
async function storageTarget(api, projectId, branch, name) {
|
|
42
|
+
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
|
|
43
|
+
return resolveSoleService(services, 'storage', name);
|
|
44
|
+
}
|
|
45
|
+
// S3 filters by prefix only — there is no substring search, so `--prefix` is the query surface.
|
|
46
|
+
export async function storageList(opts) {
|
|
47
|
+
const limit = opts.limit === undefined ? undefined : parseObjectLimit(opts.limit);
|
|
48
|
+
const api = await ApiClient.load();
|
|
49
|
+
const p = await requireProject();
|
|
50
|
+
const branch = opts.branch ?? p.branch;
|
|
51
|
+
const svc = await storageTarget(api, p.projectId, branch, opts.service);
|
|
52
|
+
const res = await api.rawRequest('GET', objectsPath(p.projectId, svc.id, { branch, prefix: opts.prefix, cursor: opts.cursor, limit }));
|
|
53
|
+
if (handleApproval(res))
|
|
54
|
+
return;
|
|
55
|
+
if (opts.json)
|
|
56
|
+
return printJson(res.body);
|
|
57
|
+
const objects = res.body?.objects ?? [];
|
|
58
|
+
if (!objects.length) {
|
|
59
|
+
return info(opts.prefix ? `(no objects under prefix ${opts.prefix} in storage/${svc.name})` : `(storage/${svc.name} is empty)`);
|
|
60
|
+
}
|
|
61
|
+
for (const o of objects)
|
|
62
|
+
info(objectListLine(o));
|
|
63
|
+
const next = res.body?.nextCursor;
|
|
64
|
+
if (next)
|
|
65
|
+
info(` (more — next page: ${nextPageCommand({ ...opts, limit }, next)})`);
|
|
66
|
+
}
|
|
67
|
+
// Safe unquoted in every shell we care about — no spaces, quotes, or expansion characters.
|
|
68
|
+
const SHELL_SAFE = /^[\w./:@=+-]+$/;
|
|
69
|
+
// The continuation must repeat the filters, or following it lists a different set. Quoting rules
|
|
70
|
+
// differ between sh and PowerShell, so rather than guess the shell, a value that would need quotes
|
|
71
|
+
// gets a sentence instead of syntax that is broken somewhere.
|
|
72
|
+
export function nextPageCommand(opts, cursor) {
|
|
73
|
+
const values = [opts.service, opts.branch, opts.prefix, cursor].filter((v) => !!v);
|
|
74
|
+
if (!values.every((v) => SHELL_SAFE.test(v))) {
|
|
75
|
+
return `re-run this command with --cursor set to ${cursor}`;
|
|
76
|
+
}
|
|
77
|
+
const flags = [
|
|
78
|
+
opts.service ? `--service ${opts.service}` : '',
|
|
79
|
+
opts.branch ? `--branch ${opts.branch}` : '',
|
|
80
|
+
opts.prefix ? `--prefix ${opts.prefix}` : '',
|
|
81
|
+
opts.limit === undefined ? '' : `--limit ${opts.limit}`,
|
|
82
|
+
`--cursor ${cursor}`,
|
|
83
|
+
].filter(Boolean);
|
|
84
|
+
return `insta storage list ${flags.join(' ')}`;
|
|
85
|
+
}
|
|
86
|
+
// pure: where the bytes land. Only the last segment is used, so no key can escape cwd.
|
|
87
|
+
export function outputPath(key, output) {
|
|
88
|
+
if (output)
|
|
89
|
+
return output;
|
|
90
|
+
// Split on `\` too: a key may contain one, and on Windows that is also a separator.
|
|
91
|
+
const base = key.split(/[\\/]/).pop() ?? '';
|
|
92
|
+
if (!base)
|
|
93
|
+
throw new Error(`cannot infer a filename from key "${key}" — pass -o <file>`);
|
|
94
|
+
return base;
|
|
95
|
+
}
|
|
96
|
+
// Stream from the provider (never through the platform, which only signs) straight to disk, so a
|
|
97
|
+
// multi-gigabyte object never has to fit in memory. Returns the byte count written.
|
|
98
|
+
export async function streamPresignedTo(url, out, fetchImpl = fetch) {
|
|
99
|
+
const res = await fetchImpl(url);
|
|
100
|
+
if (!res.ok || !res.body)
|
|
101
|
+
throw new Error(`download failed: HTTP ${res.status} (a presigned URL lives ~60s — re-run to mint a fresh one)`);
|
|
102
|
+
let written = 0;
|
|
103
|
+
const counting = new TransformStream({
|
|
104
|
+
transform(chunk, controller) { written += chunk.byteLength; controller.enqueue(chunk); },
|
|
105
|
+
});
|
|
106
|
+
// Write beside the target, then rename: opening `out` directly would truncate an existing file
|
|
107
|
+
// that a failed download then deletes. Same directory keeps the rename atomic.
|
|
108
|
+
const part = `${out}.insta-part-${randomBytes(4).toString('hex')}`;
|
|
109
|
+
// A signal kills the process without unwinding, so the part file needs a synchronous sweep.
|
|
110
|
+
// Exit 128+signo, so a supervisor still reads interrupted (130) apart from terminated (143).
|
|
111
|
+
const sweep = (signo) => () => { rmSync(part, { force: true }); process.exit(128 + signo); };
|
|
112
|
+
const onInt = sweep(2);
|
|
113
|
+
const onTerm = sweep(15);
|
|
114
|
+
process.once('SIGINT', onInt);
|
|
115
|
+
process.once('SIGTERM', onTerm);
|
|
116
|
+
try {
|
|
117
|
+
await pipeline(Readable.fromWeb(res.body.pipeThrough(counting)), createWriteStream(part));
|
|
118
|
+
// Replacing a 0600 file must not widen it to the umask default the part was created with.
|
|
119
|
+
const mode = await stat(out).then((s) => s.mode, () => undefined);
|
|
120
|
+
if (mode !== undefined)
|
|
121
|
+
await chmod(part, mode);
|
|
122
|
+
await rename(part, out);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
await rm(part, { force: true });
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
process.off('SIGINT', onInt);
|
|
130
|
+
process.off('SIGTERM', onTerm);
|
|
131
|
+
}
|
|
132
|
+
return written;
|
|
133
|
+
}
|
|
134
|
+
// Core, dependency-injected for tests (mirrors runWithSecrets): stream → disk, return byte count.
|
|
135
|
+
export async function saveObject(url, out, deps = {}) {
|
|
136
|
+
return (deps.streamTo ?? streamPresignedTo)(url, out);
|
|
137
|
+
}
|
|
138
|
+
export async function storageGet(key, opts, deps = {}) {
|
|
139
|
+
if (!key)
|
|
140
|
+
throw new Error('key is required');
|
|
141
|
+
const api = await ApiClient.load();
|
|
142
|
+
const p = await requireProject();
|
|
143
|
+
const branch = opts.branch ?? p.branch;
|
|
144
|
+
const svc = await storageTarget(api, p.projectId, branch, opts.service);
|
|
145
|
+
const res = await api.rawRequest('GET', objectDownloadPath(p.projectId, svc.id, { branch, key }));
|
|
146
|
+
if (handleApproval(res))
|
|
147
|
+
return;
|
|
148
|
+
// --json hands over the presigned URL instead of downloading, as `insta secrets --json` does.
|
|
149
|
+
// Before outputPath, so a key with no filename still works when nothing is written to disk.
|
|
150
|
+
if (opts.json)
|
|
151
|
+
return printJson(res.body);
|
|
152
|
+
const out = outputPath(key, opts.output);
|
|
153
|
+
if (!res.body?.url)
|
|
154
|
+
throw new Error('the platform returned no download URL');
|
|
155
|
+
const bytes = await saveObject(res.body.url, out, deps);
|
|
156
|
+
info(`wrote ${fmtBytes(bytes)} to ${out} (${key} from storage/${svc.name}, branch ${branch})`);
|
|
157
|
+
}
|
|
158
|
+
// No prompt, matching every other destructive command here — the governance gate is the guard.
|
|
159
|
+
export async function storageDelete(key, opts) {
|
|
160
|
+
if (!key)
|
|
161
|
+
throw new Error('key is required');
|
|
162
|
+
const api = await ApiClient.load();
|
|
163
|
+
const p = await requireProject();
|
|
164
|
+
const branch = opts.branch ?? p.branch;
|
|
165
|
+
const svc = await storageTarget(api, p.projectId, branch, opts.service);
|
|
166
|
+
const res = await api.rawRequest('DELETE', objectsPath(p.projectId, svc.id, { branch, key }));
|
|
167
|
+
if (handleApproval(res))
|
|
168
|
+
return;
|
|
169
|
+
if (opts.json)
|
|
170
|
+
return printJson(res.body);
|
|
171
|
+
info(`deleted ${key} from storage/${svc.name} (branch ${branch})`);
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=storage.js.map
|
package/dist/index.js
CHANGED
|
@@ -19,12 +19,14 @@ import * as secretsCmd from './commands/secrets.js';
|
|
|
19
19
|
import { deploy } from './commands/deploy.js';
|
|
20
20
|
import * as computeCmd from './commands/compute.js';
|
|
21
21
|
import * as dbCmd from './commands/db.js';
|
|
22
|
+
import * as storageCmd from './commands/storage.js';
|
|
22
23
|
import { manifest } from './commands/manifest.js';
|
|
23
24
|
import * as govern from './commands/govern.js';
|
|
24
25
|
import * as observe from './commands/observe.js';
|
|
25
26
|
import * as obs from './commands/metrics.js';
|
|
26
27
|
import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
|
|
27
28
|
import * as selfUpdate from './commands/upgrade.js';
|
|
29
|
+
import * as feedbackCmd from './commands/feedback.js';
|
|
28
30
|
function onError(e) {
|
|
29
31
|
if (e instanceof ApiError)
|
|
30
32
|
die(`${e.message} (HTTP ${e.status})`);
|
|
@@ -199,6 +201,25 @@ db.command('volume').description("Show or grow a postgres service's provisioned
|
|
|
199
201
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
200
202
|
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
|
|
201
203
|
.action(guard((o) => dbCmd.dbVolume(o)));
|
|
204
|
+
// ---- storage (bucket objects) ----
|
|
205
|
+
const storage = program.command('storage').description("Browse, download, and delete a storage service's bucket objects");
|
|
206
|
+
storage.command('list').description("List the bucket's objects. S3 filters by prefix only — there is no substring search")
|
|
207
|
+
.option('--prefix <p>', 'only keys starting with this prefix (applied server-side)')
|
|
208
|
+
.option('--cursor <c>', 'continue from the nextCursor a previous page printed')
|
|
209
|
+
.option('--limit <n>', 'page size, 1..1000 (default 100)')
|
|
210
|
+
.option('--service <name>', 'storage service (default: the sole one on the branch)')
|
|
211
|
+
.option('--branch <b>', 'branch (default: current)').option('--json')
|
|
212
|
+
.action(guard((o) => storageCmd.storageList(o)));
|
|
213
|
+
storage.command('get <key>').description('Download one object to disk through a short-lived presigned URL (bytes come straight from the provider)')
|
|
214
|
+
.option('-o, --output <file>', "output file (default: the key's last segment)")
|
|
215
|
+
.option('--service <name>', 'storage service (default: the sole one on the branch)')
|
|
216
|
+
.option('--branch <b>', 'branch (default: current)')
|
|
217
|
+
.option('--json', 'print the presigned URL + expiry instead of downloading')
|
|
218
|
+
.action(guard((key, o) => storageCmd.storageGet(key, o)));
|
|
219
|
+
storage.command('delete <key>').description('DELETES one object from the bucket immediately — no undo, and an already-gone key still reports success (gated: storage.delete)')
|
|
220
|
+
.option('--service <name>', 'storage service (default: the sole one on the branch)')
|
|
221
|
+
.option('--branch <b>', 'branch (default: current)').option('--json')
|
|
222
|
+
.action(guard((key, o) => storageCmd.storageDelete(key, o)));
|
|
202
223
|
// ---- manifest ----
|
|
203
224
|
program.command('manifest').description('Print an agent-legible view of the project environments').option('--json').action(guard((o) => manifest(o)));
|
|
204
225
|
// ---- regions ----
|
|
@@ -238,7 +259,24 @@ ob.command('sync').description('Upload findings into the project timeline').acti
|
|
|
238
259
|
// ---- policy ----
|
|
239
260
|
const pol = program.command('policy').description('Governance policy');
|
|
240
261
|
pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)));
|
|
241
|
-
pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d)));
|
|
262
|
+
pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d)));
|
|
263
|
+
// ---- feedback (agent + human hurdle reports → the InstaCloud team) ----
|
|
264
|
+
program.command('feedback')
|
|
265
|
+
.description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. Works logged-out and unlinked.')
|
|
266
|
+
.option('--type <type>', `what kind of hurdle: ${feedbackCmd.TYPES.join(' | ')}`)
|
|
267
|
+
.option('--component <component>', `which part of the toolkit: ${feedbackCmd.COMPONENTS.join(' | ')}`)
|
|
268
|
+
.option('--title <title>', 'one-line summary (≤200 chars)')
|
|
269
|
+
.option('--detail <text>', 'what happened vs what you expected (≤4000 chars)')
|
|
270
|
+
.option('--file <path>', 'read the detail from a file instead of --detail')
|
|
271
|
+
.option('--area <area>', 'product area, free text: deploy, branch, secrets, db, storage, compute, governance, billing, …')
|
|
272
|
+
.option('--command <cmd>', 'the insta command that hit the issue')
|
|
273
|
+
.option('--error <text>', 'error output (redacted + truncated locally before sending)')
|
|
274
|
+
.option('--expected <text>', 'what the docs/skill said should happen')
|
|
275
|
+
.option('--workaround <text>', 'what you did instead, if anything worked')
|
|
276
|
+
.option('--doc <ref>', 'doc or skill file that led you here (for stale-instruction reports)')
|
|
277
|
+
.option('--severity <severity>', `${feedbackCmd.SEVERITIES.join(' | ')} (default: minor)`)
|
|
278
|
+
.option('--json')
|
|
279
|
+
.action(guard((o) => feedbackCmd.feedback(o)));
|
|
242
280
|
// ---- self-update ----
|
|
243
281
|
program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)')
|
|
244
282
|
.action(guard(() => selfUpdate.upgrade()));
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Local PII redaction for outbound feedback text. Pattern-based: a safety net, not a license to
|
|
2
|
+
// paste credentials. The ingest service re-scrubs server-side with the same patterns
|
|
3
|
+
// (insta-feedback repo), so a drift here is caught by the second pass. IPv6 and phone numbers are
|
|
4
|
+
// deliberately not matched — the false-positive rate against UUIDs and hashes destroys the
|
|
5
|
+
// diagnostic value of error text.
|
|
6
|
+
const PATTERNS = [
|
|
7
|
+
// URL-embedded credentials: scheme://user:pass@host (DATABASE_URLs pasted into error output)
|
|
8
|
+
[/(\w+:\/\/)[^\s/@:]+:[^\s/@]+@/g, '$1[REDACTED]@'],
|
|
9
|
+
// JWTs
|
|
10
|
+
[/eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{5,}/g, '[REDACTED_JWT]'],
|
|
11
|
+
// Bearer tokens
|
|
12
|
+
[/\b[Bb]earer\s+[\w~+/.=-]{8,}/g, 'Bearer [REDACTED]'],
|
|
13
|
+
// insta_ platform tokens are insta_ + a ≥24-char Better Auth apiKey. The tail must stay ≥24:
|
|
14
|
+
// MCP tool names (insta_feedback, insta_storage_download_url, …) share the prefix with tails
|
|
15
|
+
// up to 20 chars, and they are exactly what feedback text quotes most.
|
|
16
|
+
[/\binsta_[\w-]{24,}/g, '[REDACTED_KEY]'],
|
|
17
|
+
// Common third-party key prefixes
|
|
18
|
+
[/\b(?:uak_|sk_live_|sk_test_|whsec_|ghp_|github_pat_|npm_|AIza|xox[a-z]-)[\w-]{6,}/g, '[REDACTED_KEY]'],
|
|
19
|
+
[/\bsk-[\w-]{16,}/g, '[REDACTED_KEY]'],
|
|
20
|
+
[/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]'],
|
|
21
|
+
// Generic assignments: password=..., api_key: "..."
|
|
22
|
+
[/\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\b(\s*[:=]\s*)["']?[^\s"'&,;]{4,}["']?/gi, '$1$2[REDACTED]'],
|
|
23
|
+
// Emails
|
|
24
|
+
[/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[REDACTED_EMAIL]'],
|
|
25
|
+
// Home directories carry the username (unix + windows)
|
|
26
|
+
[/\/(?:Users|home)\/[\w.-]+/g, '~'],
|
|
27
|
+
[/[A-Z]:[\\/]Users[\\/][\w.-]+/g, '~'],
|
|
28
|
+
];
|
|
29
|
+
// Public IPv4 only — private/loopback ranges are kept for their debug value.
|
|
30
|
+
// Known asymmetry with the IPv6/phone exemption above: a 4-part version or build string whose
|
|
31
|
+
// octets all fit 0–255 (e.g. "25.2.0.100") gets over-scrubbed. Accepted deliberately — dotted
|
|
32
|
+
// quads in error text are overwhelmingly real addresses, an over-scrub is recoverable noise,
|
|
33
|
+
// and an under-scrub is a leak; anchoring on surrounding context would trade that for misses.
|
|
34
|
+
const IPV4 = /\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b/g;
|
|
35
|
+
function isPrivateIp(a, b) {
|
|
36
|
+
if (a === 10 || a === 127 || a === 0)
|
|
37
|
+
return true;
|
|
38
|
+
if (a === 192 && b === 168)
|
|
39
|
+
return true;
|
|
40
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
41
|
+
return true;
|
|
42
|
+
if (a === 169 && b === 254)
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
export function redactSensitive(text) {
|
|
47
|
+
let out = text;
|
|
48
|
+
for (const [re, sub] of PATTERNS)
|
|
49
|
+
out = out.replace(re, sub);
|
|
50
|
+
out = out.replace(IPV4, (m, a, b, c, d) => {
|
|
51
|
+
const [na, nb, nc, nd] = [Number(a), Number(b), Number(c), Number(d)];
|
|
52
|
+
if (na > 255 || nb > 255 || nc > 255 || nd > 255)
|
|
53
|
+
return m;
|
|
54
|
+
return isPrivateIp(na, nb) ? m : '[REDACTED_IP]';
|
|
55
|
+
});
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** Middle truncation keeping 60% head + 40% tail — the start of an error names the failure, the
|
|
59
|
+
* end carries the actual cause; the middle is usually a stack. */
|
|
60
|
+
export function truncateMiddle(text, max) {
|
|
61
|
+
if (text.length <= max)
|
|
62
|
+
return text;
|
|
63
|
+
const marker = `…[${text.length - max} chars truncated]…`;
|
|
64
|
+
const head = Math.floor(max * 0.6);
|
|
65
|
+
const tail = max - head;
|
|
66
|
+
return text.slice(0, head) + marker + text.slice(text.length - tail);
|
|
67
|
+
}
|
|
68
|
+
/** Redact BEFORE truncating: truncating first could leave half a token visible at the cut and the
|
|
69
|
+
* redaction pattern would no longer match it. */
|
|
70
|
+
export function clean(value, max) {
|
|
71
|
+
if (typeof value !== 'string')
|
|
72
|
+
return undefined;
|
|
73
|
+
const trimmed = value.trim();
|
|
74
|
+
if (!trimmed)
|
|
75
|
+
return undefined;
|
|
76
|
+
return truncateMiddle(redactSensitive(trimmed), max);
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=redact.js.map
|