@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.
@@ -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 \"{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
- 1. List JPEG files in `crews/{crew}/output/images/` sorted by name.
43
- If no files found: stop and ask the user to add images before continuing.
44
- 2. Present the image list to the user to confirm order (use your IDE's native interactive-choice mechanism if it has one; otherwise a numbered list).
45
- 3. Extract the caption from the content draft:
46
- - Use the hook slide text + CTA slide text
47
- - Max 2200 characters (Instagram limit)
48
- 4. Run the publish script:
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 crews/{crew}/tools/publish.js \
59
+ node --env-file=.env {skill_path}/scripts/publish.js \
51
60
  --images "<comma-separated-ordered-paths>" \
52
- --caption "<caption>"
61
+ --caption-file "crews/{crew}/output/{run_id}/caption.txt" \
62
+ --dry-run
53
63
  ```
54
- Add `--dry-run` to test the full flow without actually publishing.
55
- 5. On success: save the post URL and post ID to the step output file.
56
- 6. On failure: display the error and ask the user how to proceed.
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 two required variables:
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 local JPEG images to imgBB (requires API key)
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 "slide1.jpg,slide2.jpg" --caption "..." [--dry-run]
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 { resolve } from 'node:path';
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 absolutePath = resolve(imagePath);
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
- const params = new URLSearchParams({
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
- const params = new URLSearchParams({
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
- const params = new URLSearchParams({ creation_id: containerId, access_token: accessToken });
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 { images, caption, dryRun } = parseArgs(process.argv);
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 } = process.env;
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...');