stow-cli 2.2.1 → 2.2.3
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 +10 -10
- package/dist/app-ZIHTOHXL.js +255 -0
- package/dist/backfill-BG65X4TP.js +65 -0
- package/dist/backfill-KW46AEAL.js +67 -0
- package/dist/buckets-FPMMPRR2.js +130 -0
- package/dist/buckets-JJBWUVKF.js +137 -0
- package/dist/chunk-533UGNLM.js +42 -0
- package/dist/chunk-AHBVZRDR.js +29 -0
- package/dist/chunk-KPIQZBTO.js +151 -0
- package/dist/chunk-MYFLRBWC.js +312 -0
- package/dist/chunk-NBHBVKP5.js +54 -0
- package/dist/chunk-PE6V3MVP.js +46 -0
- package/dist/chunk-RH4BOSYB.js +153 -0
- package/dist/chunk-XVKIRHTX.js +29 -0
- package/dist/cli.js +181 -199
- package/dist/delete-3UDS4RMH.js +34 -0
- package/dist/delete-CQJEGLP3.js +34 -0
- package/dist/describe-NH3K3LLW.js +79 -0
- package/dist/describe-W3ED4VW3.js +79 -0
- package/dist/drops-XO4CZ4BH.js +39 -0
- package/dist/files-BIMA5L2G.js +206 -0
- package/dist/files-SQURZ7VO.js +194 -0
- package/dist/health-3U3RHXFS.js +56 -0
- package/dist/health-TIJU6U2D.js +61 -0
- package/dist/jobs-HUW6Z6A7.js +87 -0
- package/dist/jobs-KK5IZYO5.js +99 -0
- package/dist/jobs-SX7DIN6T.js +90 -0
- package/dist/jobs-XUAXWUAK.js +102 -0
- package/dist/maintenance-7UBKZOR3.js +79 -0
- package/dist/maintenance-US3PUKFF.js +79 -0
- package/dist/mcp-TUZZB2C7.js +189 -0
- package/dist/profiles-FOLKZZRU.js +53 -0
- package/dist/profiles-XXVM3UKI.js +53 -0
- package/dist/queues-MTA2RWUP.js +56 -0
- package/dist/queues-X6IU3KBZ.js +61 -0
- package/dist/search-ULMFDWHE.js +135 -0
- package/dist/search-UWLK4OL2.js +119 -0
- package/dist/tags-OFZQ2XCX.js +90 -0
- package/dist/tags-V43DCLPQ.js +90 -0
- package/dist/upload-F5I2SJRB.js +126 -0
- package/dist/upload-N7NAVN3Q.js +126 -0
- package/dist/whoami-WUQDFC5P.js +28 -0
- package/package.json +12 -12
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/lib/validate-input.ts
|
|
2
|
+
import path from "path";
|
|
3
|
+
var InputValidationError = class extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "InputValidationError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function validateInput(value, context) {
|
|
10
|
+
if (value.includes("../") || value.includes("..\\")) {
|
|
11
|
+
throw new InputValidationError(`${context}: path traversal not allowed`);
|
|
12
|
+
}
|
|
13
|
+
if (context !== "url" && value.includes("?")) {
|
|
14
|
+
throw new InputValidationError(`${context}: embedded query parameters not allowed`);
|
|
15
|
+
}
|
|
16
|
+
if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(value)) {
|
|
17
|
+
throw new InputValidationError(`${context}: control characters not allowed`);
|
|
18
|
+
}
|
|
19
|
+
if (/%25/.test(value)) {
|
|
20
|
+
throw new InputValidationError(`${context}: double-encoded values not allowed`);
|
|
21
|
+
}
|
|
22
|
+
if (/%2[fF]/.test(value) || /%2[eE]/.test(value)) {
|
|
23
|
+
throw new InputValidationError(`${context}: percent-encoded path characters not allowed`);
|
|
24
|
+
}
|
|
25
|
+
if (context !== "url" && value.includes("#")) {
|
|
26
|
+
throw new InputValidationError(`${context}: embedded hash fragments not allowed`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function validateBucketName(name) {
|
|
31
|
+
return validateInput(name, "bucket name");
|
|
32
|
+
}
|
|
33
|
+
function validateFileKey(key) {
|
|
34
|
+
return validateInput(key, "file key");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
InputValidationError,
|
|
39
|
+
validateInput,
|
|
40
|
+
validateBucketName,
|
|
41
|
+
validateFileKey
|
|
42
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/lib/parse-json-input.ts
|
|
2
|
+
function parseJsonInput(jsonStr, flagValues) {
|
|
3
|
+
if (!jsonStr) {
|
|
4
|
+
return flagValues;
|
|
5
|
+
}
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(jsonStr);
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error(`Invalid JSON in --input-json: ${jsonStr.slice(0, 100)}`);
|
|
11
|
+
}
|
|
12
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
13
|
+
throw new Error("--input-json must be a JSON object");
|
|
14
|
+
}
|
|
15
|
+
return { ...parsed, ...stripUndefined(flagValues) };
|
|
16
|
+
}
|
|
17
|
+
function stripUndefined(obj) {
|
|
18
|
+
const result = {};
|
|
19
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
20
|
+
if (value !== void 0) {
|
|
21
|
+
result[key] = value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
parseJsonInput
|
|
29
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/lib/sanitize-response.ts
|
|
2
|
+
var INJECTION_PATTERNS = [
|
|
3
|
+
// Direct instruction injection
|
|
4
|
+
/\b(?:ignore|disregard|forget)\b.*\b(?:previous|above|prior)\b.*\b(?:instructions?|rules?|context)\b/i,
|
|
5
|
+
// System prompt extraction attempts
|
|
6
|
+
/\b(?:reveal|show|print|output|display)\b.*\b(?:system\s*prompt|instructions?|rules?)\b/i,
|
|
7
|
+
// Role hijacking
|
|
8
|
+
/\byou\s+are\s+(?:now|a)\b/i,
|
|
9
|
+
// Tool/action injection
|
|
10
|
+
/\b(?:execute|run|call)\b.*\b(?:command|tool|function|bash|shell)\b/i,
|
|
11
|
+
// Markdown/XML injection that could affect agent parsing
|
|
12
|
+
/<\/?(?:system|user|assistant|tool_use|tool_result)\b/i
|
|
13
|
+
];
|
|
14
|
+
var USER_CONTENT_FIELDS = /* @__PURE__ */ new Set([
|
|
15
|
+
"originalFilename",
|
|
16
|
+
"filename",
|
|
17
|
+
"name",
|
|
18
|
+
"description",
|
|
19
|
+
"label",
|
|
20
|
+
"text",
|
|
21
|
+
"slug",
|
|
22
|
+
"webhookUrl"
|
|
23
|
+
]);
|
|
24
|
+
function detectInjection(value) {
|
|
25
|
+
return INJECTION_PATTERNS.some((pattern) => pattern.test(value));
|
|
26
|
+
}
|
|
27
|
+
function sanitizeValue(value) {
|
|
28
|
+
if (detectInjection(value)) {
|
|
29
|
+
return `[FLAGGED: potential prompt injection] ${value}`;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function sanitizeResponse(data) {
|
|
34
|
+
if (data === null || data === void 0) {
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
if (typeof data === "string") {
|
|
38
|
+
return sanitizeValue(data);
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(data)) {
|
|
41
|
+
return data.map((item) => sanitizeResponse(item));
|
|
42
|
+
}
|
|
43
|
+
if (typeof data === "object") {
|
|
44
|
+
const result = {};
|
|
45
|
+
for (const [key, value] of Object.entries(data)) {
|
|
46
|
+
if (USER_CONTENT_FIELDS.has(key) && typeof value === "string") {
|
|
47
|
+
result[key] = sanitizeValue(value);
|
|
48
|
+
} else if (typeof value === "object" && value !== null) {
|
|
49
|
+
result[key] = sanitizeResponse(value);
|
|
50
|
+
} else {
|
|
51
|
+
result[key] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/lib/output.ts
|
|
60
|
+
var _forceHuman = false;
|
|
61
|
+
var _globalFields;
|
|
62
|
+
var _globalNdjson = false;
|
|
63
|
+
function setForceHuman(value) {
|
|
64
|
+
_forceHuman = value;
|
|
65
|
+
}
|
|
66
|
+
function setGlobalFields(fields) {
|
|
67
|
+
_globalFields = fields;
|
|
68
|
+
}
|
|
69
|
+
function setGlobalNdjson(value) {
|
|
70
|
+
_globalNdjson = value;
|
|
71
|
+
}
|
|
72
|
+
function isJsonOutput() {
|
|
73
|
+
if (_forceHuman) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return !process.stdout.isTTY;
|
|
77
|
+
}
|
|
78
|
+
function unwrapArray(data) {
|
|
79
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
const entries = Object.entries(data);
|
|
83
|
+
if (entries.length === 1 && Array.isArray(entries[0][1])) {
|
|
84
|
+
return entries[0][1];
|
|
85
|
+
}
|
|
86
|
+
return data;
|
|
87
|
+
}
|
|
88
|
+
function pickFields(obj, fieldSet) {
|
|
89
|
+
if (typeof obj !== "object" || obj === null) {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
const result = {};
|
|
93
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
94
|
+
if (fieldSet.has(key)) {
|
|
95
|
+
result[key] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
function applyFieldMask(data, fields) {
|
|
101
|
+
if (!fields) {
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
const fieldSet = new Set(fields.split(",").map((f) => f.trim()));
|
|
105
|
+
if (Array.isArray(data)) {
|
|
106
|
+
return data.map((item) => pickFields(item, fieldSet));
|
|
107
|
+
}
|
|
108
|
+
if (typeof data === "object" && data !== null) {
|
|
109
|
+
return pickFields(data, fieldSet);
|
|
110
|
+
}
|
|
111
|
+
return data;
|
|
112
|
+
}
|
|
113
|
+
function outputNdjson(items) {
|
|
114
|
+
for (const item of items) {
|
|
115
|
+
const sanitized = sanitizeResponse(item);
|
|
116
|
+
console.log(JSON.stringify(sanitized));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function output(data, humanFormatter, options) {
|
|
120
|
+
const sanitized = sanitizeResponse(data);
|
|
121
|
+
const unwrapped = _globalFields || _globalNdjson ? unwrapArray(sanitized) : sanitized;
|
|
122
|
+
const masked = applyFieldMask(unwrapped, _globalFields);
|
|
123
|
+
if (_globalNdjson && Array.isArray(masked)) {
|
|
124
|
+
outputNdjson(masked);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (options?.json || isJsonOutput()) {
|
|
128
|
+
console.log(JSON.stringify(masked, null, 2));
|
|
129
|
+
} else if (humanFormatter && !_globalFields) {
|
|
130
|
+
console.log(humanFormatter());
|
|
131
|
+
} else {
|
|
132
|
+
console.log(JSON.stringify(masked, null, 2));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function outputError(error, code, details) {
|
|
136
|
+
if (isJsonOutput()) {
|
|
137
|
+
console.error(JSON.stringify({ error, ...code ? { code } : {}, ...details }));
|
|
138
|
+
} else {
|
|
139
|
+
console.error(`Error: ${error}`);
|
|
140
|
+
}
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export {
|
|
145
|
+
setForceHuman,
|
|
146
|
+
setGlobalFields,
|
|
147
|
+
setGlobalNdjson,
|
|
148
|
+
isJsonOutput,
|
|
149
|
+
output,
|
|
150
|
+
outputError
|
|
151
|
+
};
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// src/lib/cli-docs.ts
|
|
2
|
+
var CLI_DOCS = {
|
|
3
|
+
root: {
|
|
4
|
+
description: "CLI for Stow file storage",
|
|
5
|
+
usage: "stow [command] [options]",
|
|
6
|
+
examples: [
|
|
7
|
+
"stow whoami",
|
|
8
|
+
"stow upload ./photo.jpg --bucket photos",
|
|
9
|
+
"stow drop ./screenshot.png",
|
|
10
|
+
"stow buckets",
|
|
11
|
+
"stow files photos --limit 50",
|
|
12
|
+
"stow search text 'sunset beach'",
|
|
13
|
+
"stow admin health"
|
|
14
|
+
],
|
|
15
|
+
notes: [
|
|
16
|
+
"Set STOW_API_KEY before running commands that call the API.",
|
|
17
|
+
"Set STOW_API_URL to target a non-default environment.",
|
|
18
|
+
"Set STOW_ADMIN_SECRET for admin commands."
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
drop: {
|
|
22
|
+
description: "Upload a file and get a short URL (quick share)",
|
|
23
|
+
usage: "stow drop <file> [options]",
|
|
24
|
+
examples: ["stow drop ./video.mp4", "stow drop ./notes.txt --quiet"]
|
|
25
|
+
},
|
|
26
|
+
upload: {
|
|
27
|
+
description: "Upload a file to a bucket",
|
|
28
|
+
usage: "stow upload <file> [options]",
|
|
29
|
+
examples: ["stow upload ./logo.png --bucket brand-assets", "stow upload ./clip.mov --quiet"]
|
|
30
|
+
},
|
|
31
|
+
buckets: {
|
|
32
|
+
description: "List your buckets",
|
|
33
|
+
usage: "stow buckets",
|
|
34
|
+
examples: ["stow buckets"]
|
|
35
|
+
},
|
|
36
|
+
bucketsCreate: {
|
|
37
|
+
description: "Create a new bucket",
|
|
38
|
+
usage: "stow buckets create <name> [options]",
|
|
39
|
+
examples: [
|
|
40
|
+
"stow buckets create photos",
|
|
41
|
+
'stow buckets create docs --description "Product docs"',
|
|
42
|
+
"stow buckets create public-media --public"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
bucketsRename: {
|
|
46
|
+
description: "Rename a bucket",
|
|
47
|
+
usage: "stow buckets rename <name> <new-name> [options]",
|
|
48
|
+
examples: ["stow buckets rename old-name new-name --yes"],
|
|
49
|
+
notes: ["Renaming a bucket can break existing public URLs."]
|
|
50
|
+
},
|
|
51
|
+
bucketsDelete: {
|
|
52
|
+
description: "Delete a bucket by ID",
|
|
53
|
+
usage: "stow buckets delete <id>",
|
|
54
|
+
examples: ["stow buckets delete 8f3d1ab4-..."]
|
|
55
|
+
},
|
|
56
|
+
files: {
|
|
57
|
+
description: "List files in a bucket",
|
|
58
|
+
usage: "stow files <bucket> [options]",
|
|
59
|
+
examples: [
|
|
60
|
+
"stow files photos",
|
|
61
|
+
"stow files photos --search avatars/ --limit 100",
|
|
62
|
+
"stow files photos --json"
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
filesGet: {
|
|
66
|
+
description: "Get details for a single file",
|
|
67
|
+
usage: "stow files get <bucket> <key>",
|
|
68
|
+
examples: ["stow files get photos hero.png", "stow files get photos hero.png --json"]
|
|
69
|
+
},
|
|
70
|
+
filesUpdate: {
|
|
71
|
+
description: "Update file metadata",
|
|
72
|
+
usage: "stow files update <bucket> <key> -m key=value",
|
|
73
|
+
examples: [
|
|
74
|
+
"stow files update photos hero.png -m alt='Hero image'",
|
|
75
|
+
"stow files update photos hero.png -m category=banner -m priority=high"
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
filesMissing: {
|
|
79
|
+
description: "List files missing processing data",
|
|
80
|
+
usage: "stow files missing <bucket> <type>",
|
|
81
|
+
examples: [
|
|
82
|
+
"stow files missing brera dimensions",
|
|
83
|
+
"stow files missing brera embeddings --limit 200",
|
|
84
|
+
"stow files missing brera colors --json"
|
|
85
|
+
],
|
|
86
|
+
notes: ["Valid types: dimensions, embeddings, colors"]
|
|
87
|
+
},
|
|
88
|
+
filesEnrich: {
|
|
89
|
+
description: "Generate title, description, and alt text for an image",
|
|
90
|
+
usage: "stow files enrich <bucket> <key>",
|
|
91
|
+
examples: ["stow files enrich photos hero.jpg", "stow files enrich next l5igro4iutep3"],
|
|
92
|
+
notes: [
|
|
93
|
+
"Triggers title, description, and alt text generation in parallel.",
|
|
94
|
+
"Requires a searchable bucket with image files."
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
drops: {
|
|
98
|
+
description: "List your drops with usage info",
|
|
99
|
+
usage: "stow drops",
|
|
100
|
+
examples: ["stow drops"]
|
|
101
|
+
},
|
|
102
|
+
dropsDelete: {
|
|
103
|
+
description: "Delete a drop by ID",
|
|
104
|
+
usage: "stow drops delete <id>",
|
|
105
|
+
examples: ["stow drops delete drop_abc123"]
|
|
106
|
+
},
|
|
107
|
+
delete: {
|
|
108
|
+
description: "Delete a file from a bucket",
|
|
109
|
+
usage: "stow delete <bucket> <key>",
|
|
110
|
+
examples: ["stow delete photos hero/banner.png"]
|
|
111
|
+
},
|
|
112
|
+
whoami: {
|
|
113
|
+
description: "Show account info, usage stats, and API key details",
|
|
114
|
+
usage: "stow whoami",
|
|
115
|
+
examples: ["stow whoami"]
|
|
116
|
+
},
|
|
117
|
+
open: {
|
|
118
|
+
description: "Open a bucket in the browser",
|
|
119
|
+
usage: "stow open <bucket>",
|
|
120
|
+
examples: ["stow open photos"]
|
|
121
|
+
},
|
|
122
|
+
interactive: {
|
|
123
|
+
description: "Launch interactive TUI mode",
|
|
124
|
+
usage: "stow --interactive",
|
|
125
|
+
examples: ["stow", "stow --interactive"]
|
|
126
|
+
},
|
|
127
|
+
search: {
|
|
128
|
+
description: "Search files across buckets",
|
|
129
|
+
usage: "stow search <subcommand>",
|
|
130
|
+
examples: [
|
|
131
|
+
"stow search text 'sunset beach' -b photos",
|
|
132
|
+
"stow search similar --file hero.png -b photos",
|
|
133
|
+
'stow search color --hex "#ff0000" -b photos',
|
|
134
|
+
"stow search diverse -b photos"
|
|
135
|
+
]
|
|
136
|
+
},
|
|
137
|
+
searchText: {
|
|
138
|
+
description: "Semantic text search",
|
|
139
|
+
usage: "stow search text <query> [options]",
|
|
140
|
+
examples: ["stow search text 'sunset beach' -b photos --limit 10 --json"]
|
|
141
|
+
},
|
|
142
|
+
searchSimilar: {
|
|
143
|
+
description: "Find files similar to a given file",
|
|
144
|
+
usage: "stow search similar --file <key> [options]",
|
|
145
|
+
examples: ["stow search similar --file hero.png -b photos"]
|
|
146
|
+
},
|
|
147
|
+
searchColor: {
|
|
148
|
+
description: "Search by color",
|
|
149
|
+
usage: 'stow search color --hex "#ff0000" [options]',
|
|
150
|
+
examples: ['stow search color --hex "#ff0000" -b photos --limit 20']
|
|
151
|
+
},
|
|
152
|
+
searchDiverse: {
|
|
153
|
+
description: "Diversity-aware search",
|
|
154
|
+
usage: "stow search diverse [options]",
|
|
155
|
+
examples: ["stow search diverse -b photos --limit 20"]
|
|
156
|
+
},
|
|
157
|
+
tags: {
|
|
158
|
+
description: "Manage tags",
|
|
159
|
+
usage: "stow tags",
|
|
160
|
+
examples: ["stow tags", "stow tags create 'Hero Images'", "stow tags delete <id>"]
|
|
161
|
+
},
|
|
162
|
+
tagsCreate: {
|
|
163
|
+
description: "Create a new tag",
|
|
164
|
+
usage: "stow tags create <name> [options]",
|
|
165
|
+
examples: ['stow tags create "Hero Images"', 'stow tags create "Featured" --color "#ff6600"']
|
|
166
|
+
},
|
|
167
|
+
tagsDelete: {
|
|
168
|
+
description: "Delete a tag by ID",
|
|
169
|
+
usage: "stow tags delete <id>",
|
|
170
|
+
examples: ["stow tags delete tag_abc123"]
|
|
171
|
+
},
|
|
172
|
+
profiles: {
|
|
173
|
+
description: "Manage taste profiles",
|
|
174
|
+
usage: "stow profiles <subcommand>",
|
|
175
|
+
examples: [
|
|
176
|
+
'stow profiles create --name "My Profile"',
|
|
177
|
+
"stow profiles get <id>",
|
|
178
|
+
"stow profiles delete <id>"
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
profilesCreate: {
|
|
182
|
+
description: "Create a taste profile",
|
|
183
|
+
usage: 'stow profiles create --name "My Profile" [options]',
|
|
184
|
+
examples: ['stow profiles create --name "My Profile" -b photos']
|
|
185
|
+
},
|
|
186
|
+
profilesGet: {
|
|
187
|
+
description: "Get a taste profile with clusters",
|
|
188
|
+
usage: "stow profiles get <id>",
|
|
189
|
+
examples: ["stow profiles get profile_abc123 --json"]
|
|
190
|
+
},
|
|
191
|
+
profilesDelete: {
|
|
192
|
+
description: "Delete a taste profile",
|
|
193
|
+
usage: "stow profiles delete <id>",
|
|
194
|
+
examples: ["stow profiles delete profile_abc123"]
|
|
195
|
+
},
|
|
196
|
+
jobs: {
|
|
197
|
+
description: "List processing jobs for a bucket",
|
|
198
|
+
usage: "stow jobs --bucket <id> [options]",
|
|
199
|
+
examples: [
|
|
200
|
+
"stow jobs --bucket <id>",
|
|
201
|
+
"stow jobs --bucket <id> --status failed",
|
|
202
|
+
"stow jobs --bucket <id> --queue extract-colors --json"
|
|
203
|
+
]
|
|
204
|
+
},
|
|
205
|
+
jobsRetry: {
|
|
206
|
+
description: "Retry a failed job",
|
|
207
|
+
usage: "stow jobs retry <id> --queue <name> --bucket <id>",
|
|
208
|
+
examples: ["stow jobs retry job123 --queue generate-title --bucket <id>"]
|
|
209
|
+
},
|
|
210
|
+
jobsDelete: {
|
|
211
|
+
description: "Remove a job",
|
|
212
|
+
usage: "stow jobs delete <id> --queue <name> --bucket <id>",
|
|
213
|
+
examples: ["stow jobs delete job123 --queue extract-colors --bucket <id>"]
|
|
214
|
+
},
|
|
215
|
+
admin: {
|
|
216
|
+
description: "Admin commands (requires STOW_ADMIN_SECRET)",
|
|
217
|
+
usage: "stow admin <subcommand>",
|
|
218
|
+
examples: [
|
|
219
|
+
"stow admin health",
|
|
220
|
+
"stow admin backfill dimensions --bucket <id> --dry-run",
|
|
221
|
+
"stow admin cleanup-drops --dry-run"
|
|
222
|
+
],
|
|
223
|
+
notes: ["Requires STOW_ADMIN_SECRET environment variable."]
|
|
224
|
+
},
|
|
225
|
+
adminHealth: {
|
|
226
|
+
description: "Check system health and queue depths",
|
|
227
|
+
usage: "stow admin health",
|
|
228
|
+
examples: ["stow admin health", "stow admin health --json"]
|
|
229
|
+
},
|
|
230
|
+
adminBackfill: {
|
|
231
|
+
description: "Backfill processing data for files",
|
|
232
|
+
usage: "stow admin backfill <type> [options]",
|
|
233
|
+
examples: [
|
|
234
|
+
"stow admin backfill dimensions --bucket <id> --dry-run",
|
|
235
|
+
"stow admin backfill colors --bucket <id> --limit 200",
|
|
236
|
+
"stow admin backfill embeddings --bucket <id> --limit 100 --json"
|
|
237
|
+
],
|
|
238
|
+
notes: ["Valid types: dimensions, colors, embeddings"]
|
|
239
|
+
},
|
|
240
|
+
adminCleanupDrops: {
|
|
241
|
+
description: "Remove expired drops",
|
|
242
|
+
usage: "stow admin cleanup-drops [options]",
|
|
243
|
+
examples: ["stow admin cleanup-drops --max-age-hours 24 --dry-run"]
|
|
244
|
+
},
|
|
245
|
+
adminPurgeEvents: {
|
|
246
|
+
description: "Purge old webhook events",
|
|
247
|
+
usage: "stow admin purge-events [options]",
|
|
248
|
+
examples: ["stow admin purge-events --dry-run"]
|
|
249
|
+
},
|
|
250
|
+
adminReconcileFiles: {
|
|
251
|
+
description: "Reconcile files between R2 and database",
|
|
252
|
+
usage: "stow admin reconcile-files --bucket <id>",
|
|
253
|
+
examples: ["stow admin reconcile-files --bucket <id> --dry-run"]
|
|
254
|
+
},
|
|
255
|
+
adminRetrySyncFailures: {
|
|
256
|
+
description: "Retry failed S3 sync operations",
|
|
257
|
+
usage: "stow admin retry-sync-failures",
|
|
258
|
+
examples: ["stow admin retry-sync-failures"]
|
|
259
|
+
},
|
|
260
|
+
adminJobs: {
|
|
261
|
+
description: "List and manage processing jobs",
|
|
262
|
+
usage: "stow admin jobs [options]",
|
|
263
|
+
examples: [
|
|
264
|
+
"stow admin jobs",
|
|
265
|
+
"stow admin jobs --status failed",
|
|
266
|
+
"stow admin jobs --org <id> --queue generate-title",
|
|
267
|
+
"stow admin jobs --json"
|
|
268
|
+
]
|
|
269
|
+
},
|
|
270
|
+
adminJobsRetry: {
|
|
271
|
+
description: "Retry a failed job",
|
|
272
|
+
usage: "stow admin jobs retry <id> --queue <name>",
|
|
273
|
+
examples: ["stow admin jobs retry job123 --queue generate-title"]
|
|
274
|
+
},
|
|
275
|
+
adminJobsDelete: {
|
|
276
|
+
description: "Remove a job",
|
|
277
|
+
usage: "stow admin jobs delete <id> --queue <name>",
|
|
278
|
+
examples: ["stow admin jobs delete job123 --queue extract-colors"]
|
|
279
|
+
},
|
|
280
|
+
adminQueues: {
|
|
281
|
+
description: "Show queue depths and counts",
|
|
282
|
+
usage: "stow admin queues",
|
|
283
|
+
examples: ["stow admin queues", "stow admin queues --json"]
|
|
284
|
+
},
|
|
285
|
+
adminQueuesClean: {
|
|
286
|
+
description: "Clean jobs from a queue",
|
|
287
|
+
usage: "stow admin queues clean <name> --failed|--completed",
|
|
288
|
+
examples: [
|
|
289
|
+
"stow admin queues clean generate-title --failed",
|
|
290
|
+
"stow admin queues clean extract-colors --completed --grace 3600"
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
function renderCommandHelp(key) {
|
|
295
|
+
const doc = CLI_DOCS[key];
|
|
296
|
+
const lines = [
|
|
297
|
+
"",
|
|
298
|
+
`Usage: ${doc.usage}`,
|
|
299
|
+
"",
|
|
300
|
+
"Examples:",
|
|
301
|
+
...doc.examples.map((example) => ` ${example}`)
|
|
302
|
+
];
|
|
303
|
+
if (doc.notes?.length) {
|
|
304
|
+
lines.push("", "Notes:", ...doc.notes.map((note) => ` ${note}`));
|
|
305
|
+
}
|
|
306
|
+
return lines.join("\n");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export {
|
|
310
|
+
CLI_DOCS,
|
|
311
|
+
renderCommandHelp
|
|
312
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/lib/validate-input.ts
|
|
2
|
+
import path from "path";
|
|
3
|
+
var InputValidationError = class extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "InputValidationError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function hasControlCharacters(value) {
|
|
10
|
+
for (const char of value) {
|
|
11
|
+
const codePoint = char.codePointAt(0);
|
|
12
|
+
if (codePoint === void 0) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (codePoint >= 0 && codePoint <= 8 || codePoint === 11 || codePoint === 12 || codePoint >= 14 && codePoint <= 31) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
function validateInput(value, context) {
|
|
22
|
+
if (value.includes("../") || value.includes("..\\")) {
|
|
23
|
+
throw new InputValidationError(`${context}: path traversal not allowed`);
|
|
24
|
+
}
|
|
25
|
+
if (context !== "url" && value.includes("?")) {
|
|
26
|
+
throw new InputValidationError(`${context}: embedded query parameters not allowed`);
|
|
27
|
+
}
|
|
28
|
+
if (hasControlCharacters(value)) {
|
|
29
|
+
throw new InputValidationError(`${context}: control characters not allowed`);
|
|
30
|
+
}
|
|
31
|
+
if (/%25/.test(value)) {
|
|
32
|
+
throw new InputValidationError(`${context}: double-encoded values not allowed`);
|
|
33
|
+
}
|
|
34
|
+
if (/%2[fF]/.test(value) || /%2[eE]/.test(value)) {
|
|
35
|
+
throw new InputValidationError(`${context}: percent-encoded path characters not allowed`);
|
|
36
|
+
}
|
|
37
|
+
if (context !== "url" && value.includes("#")) {
|
|
38
|
+
throw new InputValidationError(`${context}: embedded hash fragments not allowed`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function validateBucketName(name) {
|
|
43
|
+
return validateInput(name, "bucket name");
|
|
44
|
+
}
|
|
45
|
+
function validateFileKey(key) {
|
|
46
|
+
return validateInput(key, "file key");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
InputValidationError,
|
|
51
|
+
validateInput,
|
|
52
|
+
validateBucketName,
|
|
53
|
+
validateFileKey
|
|
54
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/lib/format.ts
|
|
2
|
+
function padCell(str, width) {
|
|
3
|
+
return str.padEnd(width);
|
|
4
|
+
}
|
|
5
|
+
function formatBytes(bytes) {
|
|
6
|
+
if (bytes === 0) {
|
|
7
|
+
return "0 B";
|
|
8
|
+
}
|
|
9
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
10
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
11
|
+
return `${(bytes / 1024 ** i).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
|
12
|
+
}
|
|
13
|
+
function formatTable(headers, rows) {
|
|
14
|
+
if (rows.length === 0) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
const widths = headers.map((h, i) => {
|
|
18
|
+
let dataMax = 0;
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
const cellLength = (row[i] || "").length;
|
|
21
|
+
if (cellLength > dataMax) {
|
|
22
|
+
dataMax = cellLength;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return Math.max(h.length, dataMax);
|
|
26
|
+
});
|
|
27
|
+
const sep = widths.map((w) => "\u2500".repeat(w)).join("\u2500\u2500");
|
|
28
|
+
const lines = [headers.map((h, i) => padCell(h, widths[i] ?? 0)).join(" "), sep];
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
lines.push(row.map((cell, i) => padCell(cell || "", widths[i] ?? 0)).join(" "));
|
|
31
|
+
}
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|
|
34
|
+
function usageBar(used, total, width = 20) {
|
|
35
|
+
const ratio = Math.min(used / total, 1);
|
|
36
|
+
const filled = Math.round(ratio * width);
|
|
37
|
+
const empty = width - filled;
|
|
38
|
+
const pct = Math.round(ratio * 100);
|
|
39
|
+
return `[${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}] ${pct}%`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
formatBytes,
|
|
44
|
+
formatTable,
|
|
45
|
+
usageBar
|
|
46
|
+
};
|