@aksp/opencrew 1.4.0 → 1.4.2
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 +87 -0
- package/README.md +33 -17
- package/bin/opencrew.js +4 -4
- package/package.json +4 -3
- package/src/cli.js +93 -43
- package/src/commands/init.js +79 -53
- package/src/commands/update.js +44 -17
- package/src/lib/errors.js +12 -0
- package/src/lib/fsx.js +18 -9
- package/src/lib/ides.js +9 -34
- package/templates/AGENTS.md +42 -63
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/prompts/build.prompt.md +21 -0
- package/templates/_opencrew/core/prompts/design.prompt.md +9 -5
- package/templates/_opencrew/core/runner.pipeline.md +14 -2
- package/templates/gitignore +3 -1
- package/templates/skills/image-ai-generator/SKILL.md +8 -4
- package/templates/skills/image-creator/SKILL.md +3 -1
- package/templates/skills/instagram-publisher/SKILL.md +34 -16
- package/templates/skills/instagram-publisher/scripts/publish.js +58 -27
|
@@ -20,7 +20,8 @@ version: "1.0.0"
|
|
|
20
20
|
script:
|
|
21
21
|
path: scripts/publish.js
|
|
22
22
|
runtime: node
|
|
23
|
-
invoke: "node --env-file=.env {skill_path}/scripts/publish.js --images \"{images}\" --caption \"{
|
|
23
|
+
invoke: "node --env-file=.env {skill_path}/scripts/publish.js --images \"{images}\" --caption-file \"{caption_file}\""
|
|
24
|
+
side_effects: irreversible
|
|
24
25
|
env:
|
|
25
26
|
- INSTAGRAM_ACCESS_TOKEN
|
|
26
27
|
- INSTAGRAM_USER_ID
|
|
@@ -39,36 +40,53 @@ Use the Instagram Publisher when you need to publish carousel posts directly to
|
|
|
39
40
|
|
|
40
41
|
### Workflow
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
Publishing is **irreversible**: a post cannot be taken back once it is live. This step
|
|
44
|
+
runs only at the end of the pipeline, after the Review and the Final Approval checkpoint,
|
|
45
|
+
and is **never** retried automatically.
|
|
46
|
+
|
|
47
|
+
1. Find the images produced by the rendering step **of this run** in
|
|
48
|
+
`crews/{crew}/output/{run_id}/` (use the latest `vN/` folder if there are versions),
|
|
49
|
+
JPEG only, sorted by name. If there are no `.jpg`/`.jpeg` files: stop and ask the user
|
|
50
|
+
(PNG renders must be re-rendered as JPEG — Instagram accepts JPEG only).
|
|
51
|
+
2. Extract the caption from the approved content draft (hook slide text + CTA slide text,
|
|
52
|
+
max 2200 characters) and **write it to a file**:
|
|
53
|
+
`crews/{crew}/output/{run_id}/caption.txt`. Never put the caption inside a shell command.
|
|
54
|
+
3. **Preview**: show the user the ordered image list and the full caption, and let them
|
|
55
|
+
confirm the order (use your IDE's native interactive-choice mechanism if it has one;
|
|
56
|
+
otherwise a numbered list).
|
|
57
|
+
4. **Dry run** — validates images, credentials and containers without posting:
|
|
49
58
|
```
|
|
50
|
-
node --env-file=.env
|
|
59
|
+
node --env-file=.env {skill_path}/scripts/publish.js \
|
|
51
60
|
--images "<comma-separated-ordered-paths>" \
|
|
52
|
-
--caption "
|
|
61
|
+
--caption-file "crews/{crew}/output/{run_id}/caption.txt" \
|
|
62
|
+
--dry-run
|
|
53
63
|
```
|
|
54
|
-
|
|
55
|
-
5.
|
|
56
|
-
|
|
64
|
+
If it fails: show the error and stop.
|
|
65
|
+
5. **Ask for explicit confirmation** before going live: the user must answer with the
|
|
66
|
+
word **publish** (or **publicar**). Any other answer — including silence, "ok" or an
|
|
67
|
+
ambiguous reply — means do not publish.
|
|
68
|
+
6. **Live publish** — the same command without `--dry-run`. Run it **once**.
|
|
69
|
+
7. On success: save the post URL and post ID to the step output file immediately.
|
|
70
|
+
8. On failure or missing output: do NOT run the command again. Tell the user the post may
|
|
71
|
+
already be live, ask them to check the Instagram profile, and let them decide.
|
|
57
72
|
|
|
58
73
|
### Constraints
|
|
59
74
|
|
|
60
|
-
- Images: JPEG only, 2-10 per carousel
|
|
75
|
+
- Images: JPEG only (`.jpg`/`.jpeg`), 2-10 per carousel, inside `crews/*/output/` — the
|
|
76
|
+
script refuses anything else before uploading
|
|
77
|
+
- Images are hosted on imgBB for 24h only (enough for Instagram to fetch them)
|
|
61
78
|
- Caption: max 2200 characters
|
|
62
79
|
- Requires Instagram Business account (not Personal or Creator)
|
|
63
80
|
- Rate limit: 25 API-published posts per 24 hours
|
|
64
81
|
|
|
65
82
|
### Setup (first-time)
|
|
66
83
|
|
|
67
|
-
Copy `.env.example` to `.env` and fill in the
|
|
84
|
+
Copy `.env.example` to `.env` and fill in the three required variables:
|
|
68
85
|
|
|
69
86
|
```
|
|
70
87
|
INSTAGRAM_ACCESS_TOKEN=
|
|
71
88
|
INSTAGRAM_USER_ID=
|
|
89
|
+
IMGBB_API_KEY=
|
|
72
90
|
```
|
|
73
91
|
|
|
74
92
|
#### INSTAGRAM_ACCESS_TOKEN
|
|
@@ -115,5 +133,5 @@ Pré-requisito: conta Instagram Business conectada a uma Página do Facebook, e
|
|
|
115
133
|
|
|
116
134
|
- **Publish Carousel** -- Upload images and publish a carousel post to Instagram
|
|
117
135
|
- **Dry Run** -- Test the full publishing flow without actually posting (use `--dry-run` flag)
|
|
118
|
-
- **Image Upload** -- Upload
|
|
136
|
+
- **Image Upload** -- Upload the crew's JPEG renders to imgBB for 24h (requires API key)
|
|
119
137
|
- **Status Check** -- Monitor media container processing status before publishing
|
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Instagram Carousel Publisher
|
|
3
|
-
// Usage: node --env-file=.env publish.js --images "
|
|
3
|
+
// Usage: node --env-file=.env publish.js --images "a.jpg,b.jpg" --caption-file caption.txt [--dry-run]
|
|
4
|
+
//
|
|
5
|
+
// Safety rules (specs/fase-1-hotfix.md F1-10):
|
|
6
|
+
// - the caption comes from a FILE, never from a shell-interpolated argument;
|
|
7
|
+
// - only .jpg/.jpeg files inside crews/*/output/ can be uploaded (public hosting);
|
|
8
|
+
// - imgBB uploads expire; the Graph API token goes in POST bodies, not URLs.
|
|
4
9
|
|
|
5
10
|
import { readFileSync } from 'node:fs';
|
|
6
|
-
import {
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { resolve, relative, isAbsolute, sep, extname } from 'node:path';
|
|
7
13
|
import { fileURLToPath } from 'node:url';
|
|
8
14
|
|
|
15
|
+
const IMGBB_EXPIRATION_SECONDS = 86_400; // Instagram fetches the images right away; 1 day is plenty.
|
|
16
|
+
|
|
9
17
|
// ── Argument parsing ──────────────────────────────────────────
|
|
10
18
|
|
|
11
19
|
export function parseArgs(argv) {
|
|
12
|
-
const args = { images: [], caption: '', dryRun: false };
|
|
20
|
+
const args = { images: [], caption: '', captionFile: '', dryRun: false };
|
|
13
21
|
for (let i = 2; i < argv.length; i++) {
|
|
14
22
|
if (argv[i] === '--images') {
|
|
15
|
-
if (i + 1 < argv.length) args.images = argv[++i].split(',').map(s => s.trim());
|
|
23
|
+
if (i + 1 < argv.length) args.images = argv[++i].split(',').map(s => s.trim()).filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
else if (argv[i] === '--caption-file') {
|
|
26
|
+
if (i + 1 < argv.length) args.captionFile = argv[++i];
|
|
16
27
|
}
|
|
17
28
|
else if (argv[i] === '--caption') {
|
|
29
|
+
// Legacy: prefer --caption-file (a caption in a shell command can execute code).
|
|
18
30
|
if (i + 1 < argv.length) args.caption = argv[++i];
|
|
19
31
|
}
|
|
20
32
|
else if (argv[i] === '--dry-run') args.dryRun = true;
|
|
@@ -22,15 +34,32 @@ export function parseArgs(argv) {
|
|
|
22
34
|
return args;
|
|
23
35
|
}
|
|
24
36
|
|
|
37
|
+
export async function readCaption(args) {
|
|
38
|
+
return args.captionFile ? readFile(args.captionFile, 'utf8') : args.caption;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Throws unless every path is a .jpg/.jpeg inside <cwd>/crews/<crew>/output/. */
|
|
42
|
+
export function validateImagePaths(images, cwd = process.cwd()) {
|
|
43
|
+
const crewsDir = resolve(cwd, 'crews');
|
|
44
|
+
for (const img of images) {
|
|
45
|
+
const rel = relative(crewsDir, resolve(cwd, img));
|
|
46
|
+
const parts = rel.split(sep);
|
|
47
|
+
const inside = rel && !rel.startsWith('..') && !isAbsolute(rel) && parts.length >= 3 && parts[1] === 'output';
|
|
48
|
+
const jpeg = ['.jpg', '.jpeg'].includes(extname(img).toLowerCase());
|
|
49
|
+
if (!inside || !jpeg) {
|
|
50
|
+
throw new Error(`Refusing to upload ${img}: only .jpg/.jpeg files inside crews/*/output/ are allowed.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
// ── Image upload (imgBB) ──────────────────────────────────────
|
|
26
56
|
|
|
27
57
|
export async function uploadToImgBB(imagePath, apiKey) {
|
|
28
|
-
const
|
|
29
|
-
const fileBuffer = readFileSync(absolutePath);
|
|
30
|
-
const base64Image = fileBuffer.toString('base64');
|
|
58
|
+
const base64Image = readFileSync(resolve(imagePath)).toString('base64');
|
|
31
59
|
const form = new FormData();
|
|
32
60
|
form.append('key', apiKey);
|
|
33
61
|
form.append('image', base64Image);
|
|
62
|
+
form.append('expiration', String(IMGBB_EXPIRATION_SECONDS));
|
|
34
63
|
const res = await fetch('https://api.imgbb.com/1/upload', {
|
|
35
64
|
method: 'POST',
|
|
36
65
|
body: form,
|
|
@@ -45,15 +74,19 @@ export async function uploadToImgBB(imagePath, apiKey) {
|
|
|
45
74
|
|
|
46
75
|
const IG_BASE = 'https://graph.facebook.com/v21.0';
|
|
47
76
|
|
|
77
|
+
// POST with form-encoded body: keeps the access token out of URLs (and out of server/proxy logs).
|
|
78
|
+
async function igPost(path, params, label) {
|
|
79
|
+
const res = await fetch(`${IG_BASE}/${path}`, { method: 'POST', body: new URLSearchParams(params) });
|
|
80
|
+
if (!res.ok) throw new Error(`${label} failed [${res.status}]: ${await res.text()}`);
|
|
81
|
+
return (await res.json()).id;
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
export async function createChildContainer(userId, imageUrl, accessToken) {
|
|
49
|
-
|
|
85
|
+
return igPost(`${userId}/media`, {
|
|
50
86
|
image_url: imageUrl,
|
|
51
87
|
is_carousel_item: 'true',
|
|
52
88
|
access_token: accessToken,
|
|
53
|
-
});
|
|
54
|
-
const res = await fetch(`${IG_BASE}/${userId}/media?${params}`, { method: 'POST' });
|
|
55
|
-
if (!res.ok) throw new Error(`createChildContainer failed [${res.status}]: ${await res.text()}`);
|
|
56
|
-
return (await res.json()).id;
|
|
89
|
+
}, 'createChildContainer');
|
|
57
90
|
}
|
|
58
91
|
|
|
59
92
|
export async function getContainerStatus(containerId, accessToken) {
|
|
@@ -75,22 +108,16 @@ export async function pollUntilFinished(containerId, accessToken, timeoutMs = 60
|
|
|
75
108
|
}
|
|
76
109
|
|
|
77
110
|
export async function createCarouselContainer(userId, childIds, caption, accessToken) {
|
|
78
|
-
|
|
111
|
+
return igPost(`${userId}/media`, {
|
|
79
112
|
media_type: 'CAROUSEL',
|
|
80
113
|
children: childIds.join(','),
|
|
81
114
|
caption,
|
|
82
115
|
access_token: accessToken,
|
|
83
|
-
});
|
|
84
|
-
const res = await fetch(`${IG_BASE}/${userId}/media?${params}`, { method: 'POST' });
|
|
85
|
-
if (!res.ok) throw new Error(`createCarouselContainer failed [${res.status}]: ${await res.text()}`);
|
|
86
|
-
return (await res.json()).id;
|
|
116
|
+
}, 'createCarouselContainer');
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
export async function publishMedia(userId, containerId, accessToken) {
|
|
90
|
-
|
|
91
|
-
const res = await fetch(`${IG_BASE}/${userId}/media_publish?${params}`, { method: 'POST' });
|
|
92
|
-
if (!res.ok) throw new Error(`publishMedia failed [${res.status}]: ${await res.text()}`);
|
|
93
|
-
return (await res.json()).id;
|
|
120
|
+
return igPost(`${userId}/media_publish`, { creation_id: containerId, access_token: accessToken }, 'publishMedia');
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
export async function getPermalink(mediaId, accessToken) {
|
|
@@ -103,25 +130,29 @@ export async function getPermalink(mediaId, accessToken) {
|
|
|
103
130
|
|
|
104
131
|
// ── Main ──────────────────────────────────────────────────────
|
|
105
132
|
|
|
106
|
-
async function main() {
|
|
107
|
-
const
|
|
133
|
+
export async function main(argv = process.argv, { env = process.env, cwd = process.cwd() } = {}) {
|
|
134
|
+
const args = parseArgs(argv);
|
|
135
|
+
const { images, dryRun } = args;
|
|
108
136
|
|
|
109
137
|
if (!images.length) throw new Error('--images is required (e.g. --images "slide1.jpg,slide2.jpg")');
|
|
110
|
-
if (!caption) throw new Error('--caption is required');
|
|
111
138
|
if (images.length < 2 || images.length > 10) {
|
|
112
139
|
throw new Error(`Instagram carousels require 2–10 images (got ${images.length})`);
|
|
113
140
|
}
|
|
141
|
+
validateImagePaths(images, cwd);
|
|
142
|
+
|
|
143
|
+
const caption = await readCaption(args);
|
|
144
|
+
if (!caption) throw new Error('--caption-file is required');
|
|
114
145
|
if (caption.length > 2200) {
|
|
115
146
|
throw new Error(`Caption exceeds Instagram's 2200-character limit (got ${caption.length})`);
|
|
116
147
|
}
|
|
117
148
|
|
|
118
|
-
const { INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_USER_ID, IMGBB_API_KEY } =
|
|
149
|
+
const { INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_USER_ID, IMGBB_API_KEY } = env;
|
|
119
150
|
if (!INSTAGRAM_ACCESS_TOKEN) throw new Error('INSTAGRAM_ACCESS_TOKEN is not set in environment');
|
|
120
151
|
if (!INSTAGRAM_USER_ID) throw new Error('INSTAGRAM_USER_ID is not set in environment');
|
|
121
152
|
if (!IMGBB_API_KEY) throw new Error('IMGBB_API_KEY is not set in environment. Get one at https://api.imgbb.com/');
|
|
122
153
|
|
|
123
|
-
console.log(`📸 Uploading ${images.length} image(s) to imgBB...`);
|
|
124
|
-
const imageUrls = await Promise.all(images.map(p => uploadToImgBB(p, IMGBB_API_KEY)));
|
|
154
|
+
console.log(`📸 Uploading ${images.length} image(s) to imgBB (expire in 24h)...`);
|
|
155
|
+
const imageUrls = await Promise.all(images.map(p => uploadToImgBB(resolve(cwd, p), IMGBB_API_KEY)));
|
|
125
156
|
imageUrls.forEach((url, i) => console.log(` [${i + 1}] ${url}`));
|
|
126
157
|
|
|
127
158
|
console.log('\n📦 Creating Instagram media containers...');
|