@colony2/c2j 0.0.1 → 0.0.4

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 ADDED
@@ -0,0 +1,453 @@
1
+ # c2j
2
+
3
+ `c2j` is the local job-oriented CLI for submitting and running recipe jobs through an SWF runtime.
4
+
5
+ Use it when you want to:
6
+
7
+ - submit a recipe job from a named recipe or a local recipe file
8
+ - run or continue an existing job
9
+ - use the embedded local runtime for fast iteration
10
+ - inspect the current cell configuration used for job targeting
11
+ - list jobs for a cell
12
+
13
+ Examples below assume you are running from the repo root.
14
+
15
+ ## Command Summary
16
+
17
+ ```bash
18
+ c2j self
19
+ c2j cells
20
+ c2j init
21
+ c2j submit
22
+ c2j exec
23
+ c2j list
24
+ ```
25
+
26
+ Use `go run ./cmd/c2j --help` or `c2j --help` to see the full command tree.
27
+
28
+ ## Quick Start
29
+
30
+ ### 1. Check current-cell resolution
31
+
32
+ `submit` targets the current cell by default. That usually comes from `.c2j/config.yaml`, but supported project types can also be auto-detected.
33
+
34
+ Inspect the resolved config:
35
+
36
+ ```bash
37
+ c2j self
38
+ ```
39
+
40
+ List allowed dependent cells:
41
+
42
+ ```bash
43
+ c2j cells
44
+ ```
45
+
46
+ Generate a starter config if needed:
47
+
48
+ ```bash
49
+ c2j init --stdout
50
+ ```
51
+
52
+ If the current directory does not resolve as a cell, either:
53
+
54
+ - create `.c2j/config.yaml`, or
55
+ - pass `--cell <repo-or-path>` explicitly to `submit` or `list`
56
+
57
+ ### 2. Submit and run a local recipe file
58
+
59
+ For local authoring, this is the default loop:
60
+
61
+ ```bash
62
+ c2j submit \
63
+ --recipe-file ./recipes/my-recipe.yaml \
64
+ --run \
65
+ --embed
66
+ ```
67
+
68
+ That does all of the following:
69
+
70
+ - loads the local YAML file
71
+ - embeds that recipe into the submitted job
72
+ - starts an embedded SWF runtime
73
+ - submits the job
74
+ - immediately executes it
75
+
76
+ ### 3. Continue or inspect a job later
77
+
78
+ Find recent jobs for the current cell:
79
+
80
+ ```bash
81
+ c2j list --self --embed
82
+ ```
83
+
84
+ Continue a submitted job:
85
+
86
+ ```bash
87
+ c2j exec --job-id <job-id> --embed
88
+ ```
89
+
90
+ ## Current Cell Commands
91
+
92
+ ### `c2j self`
93
+
94
+ Shows how `c2j` resolves the current cell from `.c2j/config.yaml` or supported auto-detection.
95
+
96
+ ```bash
97
+ c2j self
98
+ c2j self --json
99
+ ```
100
+
101
+ Fields include:
102
+
103
+ - `short_name`
104
+ - `repo`
105
+ - `ref`
106
+ - `root_repo`
107
+ - `root_ref`
108
+ - `pattern`
109
+
110
+ ### `c2j cells`
111
+
112
+ Lists dependent cells allowed by the current config.
113
+
114
+ ```bash
115
+ c2j cells
116
+ c2j cells --json
117
+ ```
118
+
119
+ This is mainly useful when you want to target another cell by short name and need to verify how config expands it.
120
+
121
+ ### `c2j init`
122
+
123
+ Writes a commented `.c2j/config.yaml` template.
124
+
125
+ ```bash
126
+ c2j init
127
+ c2j init --stdout
128
+ c2j init --force
129
+ ```
130
+
131
+ The generated template can derive values from the Go module in the current repo when `base: go` is appropriate.
132
+
133
+ ## Submitting Jobs
134
+
135
+ ### Basic forms
136
+
137
+ Submit a named recipe:
138
+
139
+ ```bash
140
+ c2j submit --recipe default --embed
141
+ ```
142
+
143
+ `--recipe` accepts a recipe name or git selector. For local files, prefer `--recipe-file`.
144
+
145
+ Submit a local recipe file:
146
+
147
+ ```bash
148
+ c2j submit --recipe-file ./recipes/my-recipe.yaml --embed
149
+ ```
150
+
151
+ Submit and run immediately:
152
+
153
+ ```bash
154
+ c2j submit --recipe-file ./recipes/my-recipe.yaml --run --embed
155
+ ```
156
+
157
+ By default, if neither `--recipe` nor `--recipe-file` is set, `c2j` submits the recipe named `default`.
158
+
159
+ ### Passing inputs
160
+
161
+ Inline JSON:
162
+
163
+ ```bash
164
+ c2j submit \
165
+ --recipe-file ./recipes/my-recipe.yaml \
166
+ --inputs-json '{"message":"hello"}' \
167
+ --run \
168
+ --embed
169
+ ```
170
+
171
+ Inputs file in JSON or YAML:
172
+
173
+ ```bash
174
+ c2j submit \
175
+ --recipe-file ./recipes/my-recipe.yaml \
176
+ --inputs-file ./recipes/test-inputs.yaml \
177
+ --run \
178
+ --embed
179
+ ```
180
+
181
+ Positional prompt shortcut:
182
+
183
+ ```bash
184
+ c2j submit "Summarize the repo" --recipe my-prompt-recipe --embed
185
+ ```
186
+
187
+ The positional argument is merged as `inputs.prompt`.
188
+
189
+ Rules:
190
+
191
+ - `--inputs-json` and `--inputs-file` are mutually exclusive
192
+ - the positional prompt cannot also be provided as `inputs.prompt`
193
+
194
+ ### Choosing the target cell
195
+
196
+ Use the current cell:
197
+
198
+ ```bash
199
+ c2j submit --recipe-file ./recipes/my-recipe.yaml --self --embed
200
+ ```
201
+
202
+ Use another cell explicitly:
203
+
204
+ ```bash
205
+ c2j submit \
206
+ --recipe-file ./recipes/my-recipe.yaml \
207
+ --cell github.com/colony-2/root \
208
+ --embed
209
+ ```
210
+
211
+ `--cell` accepts:
212
+
213
+ - a canonical repo string
214
+ - a clone URL
215
+ - a local repository path
216
+ - a configured short name when `.c2j/config.yaml` defines a pattern
217
+
218
+ Rules:
219
+
220
+ - `--self` and `--cell` are mutually exclusive
221
+ - if no `--cell` is given, `c2j` behaves as if you targeted `--self`
222
+
223
+ ### Getting machine-readable output
224
+
225
+ If you only want the submitted job identity:
226
+
227
+ ```bash
228
+ c2j submit \
229
+ --recipe-file ./recipes/my-recipe.yaml \
230
+ --json \
231
+ --embed
232
+ ```
233
+
234
+ This emits:
235
+
236
+ ```json
237
+ {
238
+ "tenant_id": "0",
239
+ "job_id": "job-...",
240
+ "recipe": "my_recipe_id"
241
+ }
242
+ ```
243
+
244
+ Note:
245
+
246
+ - `--json` and `--run` are mutually exclusive
247
+
248
+ ## Running Jobs
249
+
250
+ `c2j exec` executes or continues an existing job and prints live story progress to stdout.
251
+
252
+ Basic usage:
253
+
254
+ ```bash
255
+ c2j exec --job-id <job-id> --embed
256
+ ```
257
+
258
+ Common variants:
259
+
260
+ ```bash
261
+ c2j exec --job-id <job-id> --wait-timeout 30m --embed
262
+ c2j exec --job-id <job-id> --input-mode fail --embed
263
+ c2j exec --job-id <job-id> --ci --embed
264
+ ```
265
+
266
+ Important behavior:
267
+
268
+ - completed jobs return successfully
269
+ - failed jobs return a non-zero exit code
270
+ - suspended jobs may wait, prompt, or fail depending on flags
271
+ - when input is pending, interactive terminals default to prompting
272
+ - in CI or non-terminal mode, input handling defaults to `ops`
273
+
274
+ ### Input handling
275
+
276
+ `--input-mode` controls what happens when a job is blocked on user input:
277
+
278
+ - `prompt`
279
+ Prompt on stdin/stdout and submit the response
280
+ - `ops`
281
+ Emit machine-readable `input_required` JSON and exit non-zero
282
+ - `fail`
283
+ Exit immediately when input is required
284
+
285
+ `--ci` enables machine-readable input-required behavior without prompting.
286
+
287
+ ### Not-ready handling
288
+
289
+ `--on-not-ready` controls how `exec` reacts when a job is not runnable yet:
290
+
291
+ - `wait`
292
+ - `fail`
293
+ - `fail-on-lease`
294
+ - `fail-on-pending-jobs`
295
+ - `fail-on-future`
296
+ - `fail-on-missing-capability`
297
+
298
+ With the default `wait`, `exec` will print `waiting: ...` lines and poll until the job becomes runnable or the wait timeout is reached.
299
+
300
+ ### Exit codes
301
+
302
+ `c2j exec` uses distinct exit codes:
303
+
304
+ - `1`: general failure or job failure
305
+ - `2`: wait timeout
306
+ - `3`: input required
307
+ - `4`: job not runnable under the selected policy
308
+ - `5`: invalid job identity or invalid exec arguments
309
+
310
+ ## Listing Jobs
311
+
312
+ List jobs for the current cell:
313
+
314
+ ```bash
315
+ c2j list --self --embed
316
+ ```
317
+
318
+ List as JSON:
319
+
320
+ ```bash
321
+ c2j list --self --json --embed
322
+ ```
323
+
324
+ Filter by status:
325
+
326
+ ```bash
327
+ c2j list --self --status pending_jobs --status active --embed
328
+ ```
329
+
330
+ List jobs for another cell:
331
+
332
+ ```bash
333
+ c2j list --cell github.com/colony-2/root --embed
334
+ ```
335
+
336
+ Useful filters:
337
+
338
+ - `--job-id`
339
+ - `--job-type`
340
+ - `--status`
341
+ - `--waiting-for`
342
+ - `--created-after`
343
+ - `--created-before`
344
+ - `--page-size`
345
+ - `--page-token`
346
+ - `--all`
347
+
348
+ ## Embedded Runtime
349
+
350
+ `--embed` is shorthand for:
351
+
352
+ ```bash
353
+ --swf-url embed:///
354
+ ```
355
+
356
+ Use it when you want a local self-contained runtime instead of an external SWF server.
357
+
358
+ Behavior:
359
+
360
+ - starts embedded Postgres and Strata as needed
361
+ - uses a persistent runtime root on disk
362
+ - works well for local recipe authoring and debugging
363
+
364
+ Defaults:
365
+
366
+ - runtime URL: `embed:///`
367
+ - runtime root: `~/.c2j/embed/default`
368
+
369
+ You can override the root with:
370
+
371
+ ```bash
372
+ export C2J_EMBED_ROOT=/absolute/path/to/embed-root
373
+ ```
374
+
375
+ Notes:
376
+
377
+ - `C2J_EMBED_ROOT` must be an absolute path
378
+ - only one `c2j` process can own a given embedded runtime root at a time
379
+ - if you need parallel embedded runtimes, give each process a different `C2J_EMBED_ROOT`
380
+
381
+ ## Runtime and Tenant Defaults
382
+
383
+ `c2j` reads these environment variables:
384
+
385
+ - `C2J_SWF_URL`
386
+ - `C2J_TENANT_ID`
387
+ - `C2J_EMBED_ROOT`
388
+
389
+ Built-in defaults:
390
+
391
+ - `C2J_SWF_URL`: `http://localhost:9047`
392
+ - `C2J_TENANT_ID`: `0`
393
+
394
+ Examples:
395
+
396
+ ```bash
397
+ export C2J_SWF_URL=http://localhost:9047
398
+ export C2J_TENANT_ID=123
399
+ ```
400
+
401
+ When `--embed` is present, it overrides `C2J_SWF_URL` for that command.
402
+
403
+ ## Common Workflows
404
+
405
+ ### Local recipe authoring loop
406
+
407
+ ```bash
408
+ c2j self
409
+ c2j submit --recipe-file ./recipes/my-recipe.yaml --run --embed
410
+ ```
411
+
412
+ ### Detached submit, then later run
413
+
414
+ ```bash
415
+ c2j submit --recipe-file ./recipes/my-recipe.yaml --json --embed
416
+ c2j exec --job-id <job-id> --embed
417
+ ```
418
+
419
+ ### Run against a remote runtime instead of embed
420
+
421
+ ```bash
422
+ c2j submit \
423
+ --recipe-file ./recipes/my-recipe.yaml \
424
+ --swf-url http://localhost:9047 \
425
+ --run
426
+ ```
427
+
428
+ ### Target another cell explicitly
429
+
430
+ ```bash
431
+ c2j submit \
432
+ --recipe-file ./recipes/my-recipe.yaml \
433
+ --cell github.com/colony-2/root \
434
+ --run \
435
+ --embed
436
+ ```
437
+
438
+ ## Gotchas
439
+
440
+ - `--recipe` and `--recipe-file` are mutually exclusive
441
+ - `--json` and `--run` are mutually exclusive on `submit`
442
+ - `--inputs-json` and `--inputs-file` are mutually exclusive
443
+ - `--self` and `--cell` are mutually exclusive
444
+ - `self`, `cells`, and implicit current-cell submission depend on config or supported auto-detection succeeding
445
+ - short cell names require a config pattern; without config, use an explicit repo or path
446
+ - `--recipe-file` is clearer than passing a local file path through `--recipe`
447
+
448
+ ## Related Files
449
+
450
+ - command entrypoint: [main.go](main.go)
451
+ - embedded runtime notes: [embed-swf-mode-spec.md](embed-swf-mode-spec.md)
452
+ - recipe authoring docs: [RECIPE_AUTHORING_GUIDE.md](../../recipes/guides/RECIPE_AUTHORING_GUIDE.md)
453
+
package/install.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ // This file was generated by GoReleaser. DO NOT EDIT.
4
+
5
+ import { install } from "./lib.js";
6
+ install();
package/lib.js ADDED
@@ -0,0 +1,259 @@
1
+ // This file was generated by GoReleaser. DO NOT EDIT.
2
+ import fs from "fs";
3
+ import crypto from "crypto";
4
+ import http from "http";
5
+ import https from "https";
6
+ import path from "path";
7
+ import JSZip from "jszip";
8
+ import { x as tarExtract } from "tar";
9
+ import { ProxyAgent } from "proxy-agent";
10
+ import { spawnSync } from "child_process";
11
+ import { fileURLToPath } from "url";
12
+
13
+ const { archives, name, version } = JSON.parse(
14
+ fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"),
15
+ );
16
+
17
+ const agent = new ProxyAgent();
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ const getArchive = () => {
21
+ let target = `${process.platform}-${process.arch}`;
22
+ const archive = archives[target];
23
+ if (!archive) {
24
+ throw new Error(`No archive available for ${target}`);
25
+ }
26
+ return archive;
27
+ };
28
+
29
+ const binDir = path.join(__dirname, "bin");
30
+
31
+ async function extractTar(tarPath, binaries, dir, wrappedIn) {
32
+ try {
33
+ const filesToExtract = wrappedIn
34
+ ? binaries.map((bin) =>
35
+ path.join(wrappedIn, bin).replace(/\\/g, "/"),
36
+ )
37
+ : binaries;
38
+
39
+ await tarExtract({
40
+ file: tarPath,
41
+ cwd: dir,
42
+ filter: (path) => filesToExtract.includes(path),
43
+ });
44
+
45
+ // If wrapped, move files from wrapped directory to bin directory
46
+ if (wrappedIn) {
47
+ const wrappedDir = path.join(dir, wrappedIn);
48
+ for (const binary of binaries) {
49
+ const srcPath = path.join(wrappedDir, binary);
50
+ const destPath = path.join(dir, binary);
51
+ if (fs.existsSync(srcPath)) {
52
+ fs.renameSync(srcPath, destPath);
53
+ }
54
+ }
55
+ // Clean up empty wrapped directory
56
+ try {
57
+ fs.rmSync(wrappedDir, { recursive: true, force: true });
58
+ } catch (err) {
59
+ // Ignore cleanup errors
60
+ }
61
+ }
62
+
63
+ console.log(`Successfully extracted ${binaries} to "${dir}"`);
64
+ } catch (err) {
65
+ throw new Error(`Extraction failed: ${err.message}`);
66
+ }
67
+ }
68
+
69
+ async function extractZip(zipPath, binaries, dir, wrappedIn) {
70
+ try {
71
+ const zipData = fs.readFileSync(zipPath);
72
+ const zip = await JSZip.loadAsync(zipData);
73
+
74
+ for (const binary of binaries) {
75
+ const binaryPath = wrappedIn
76
+ ? path.join(wrappedIn, binary).replace(/\\/g, "/")
77
+ : binary;
78
+
79
+ if (!zip.files[binaryPath]) {
80
+ throw new Error(
81
+ `Error: ${binaryPath} does not exist in ${zipPath}`,
82
+ );
83
+ }
84
+
85
+ const content = await zip.files[binaryPath].async("nodebuffer");
86
+ if (!fs.existsSync(dir)) {
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ }
89
+ const file = path.join(dir, binary);
90
+ fs.writeFileSync(file, content);
91
+ fs.chmodSync(file, "755");
92
+ console.log(`Successfully extracted "${binary}" to "${dir}"`);
93
+ }
94
+ } catch (err) {
95
+ throw new Error(`Extraction failed: ${err.message}`);
96
+ }
97
+ }
98
+
99
+ const run = async (bin) => {
100
+ await install();
101
+ if (process.platform === "win32") {
102
+ bin += ".exe";
103
+ }
104
+ const [, , ...args] = process.argv;
105
+ let result = spawnSync(path.join(binDir, bin), args, {
106
+ cwd: process.cwd(),
107
+ stdio: "inherit",
108
+ });
109
+ if (result.error) {
110
+ console.error(result.error);
111
+ }
112
+ return result.status;
113
+ };
114
+
115
+ const install = async () => {
116
+ try {
117
+ let archive = getArchive();
118
+ if (await exists(archive)) {
119
+ return;
120
+ }
121
+ let tmp = fs.mkdtempSync("archive-");
122
+ let archivePath = path.join(tmp, archive.name);
123
+ await download(archive.url, archivePath);
124
+ verify(archivePath, archive.checksum);
125
+
126
+ if (!fs.existsSync(binDir)) {
127
+ fs.mkdirSync(binDir);
128
+ }
129
+ switch (archive.format) {
130
+ case "binary":
131
+ const bin = path.join(binDir, archive.bins[0]);
132
+ fs.copyFileSync(archivePath, bin);
133
+ fs.chmodSync(bin, 0o755);
134
+ break;
135
+ case "zip":
136
+ await extractZip(
137
+ archivePath,
138
+ archive.bins,
139
+ binDir,
140
+ archive.wrappedIn,
141
+ );
142
+ break;
143
+ case "tar":
144
+ case "tar.gz":
145
+ case "tgz":
146
+ await extractTar(
147
+ archivePath,
148
+ archive.bins,
149
+ binDir,
150
+ archive.wrappedIn,
151
+ );
152
+ break;
153
+ case "tar.zst":
154
+ case "tzst":
155
+ case "tar.xz":
156
+ case "txz":
157
+ default:
158
+ throw new Error(`unsupported format: ${archive.format}`);
159
+ }
160
+ console.log(`Installed ${name} ${version} to ${binDir}`);
161
+ } catch (err) {
162
+ throw new Error(`Installation failed: ${err.message}`);
163
+ }
164
+ };
165
+
166
+ const verify = (filename, checksum) => {
167
+ if (checksum.algorithm == "" || checksum.digest == "") {
168
+ console.warn("Warning: No checksum provided for verification");
169
+ return;
170
+ }
171
+ let digest = crypto
172
+ .createHash(checksum.algorithm)
173
+ .update(fs.readFileSync(filename))
174
+ .digest("hex");
175
+ if (digest != checksum.digest) {
176
+ throw new Error(
177
+ `${filename}: ${checksum.algorithm} does not match, expected ${checksum.digest}, got ${digest}`,
178
+ );
179
+ }
180
+ };
181
+
182
+ const download = async (url, filename, maxRedirects = 10) => {
183
+ try {
184
+ console.log(`Downloading ${url} to ${filename}...`);
185
+ const dir = path.dirname(filename);
186
+ if (!fs.existsSync(dir)) {
187
+ fs.mkdirSync(dir, { recursive: true });
188
+ }
189
+
190
+ return new Promise((resolve, reject) => {
191
+ const parsedUrl = new URL(url);
192
+ const mod = parsedUrl.protocol === "https:" ? https : http;
193
+
194
+ const request = mod.get(url, { agent }, (response) => {
195
+ if (
196
+ response.statusCode >= 300 &&
197
+ response.statusCode < 400 &&
198
+ response.headers.location
199
+ ) {
200
+ if (maxRedirects <= 0) {
201
+ reject(new Error("Too many redirects"));
202
+ return;
203
+ }
204
+ download(response.headers.location, filename, maxRedirects - 1)
205
+ .then(resolve)
206
+ .catch(reject);
207
+ return;
208
+ }
209
+
210
+ if (response.statusCode !== 200) {
211
+ reject(
212
+ new Error(
213
+ `HTTP ${response.statusCode}: ${response.statusMessage}`,
214
+ ),
215
+ );
216
+ return;
217
+ }
218
+
219
+ const writer = fs.createWriteStream(filename);
220
+ response.pipe(writer);
221
+
222
+ writer.on("finish", () => {
223
+ console.log(`Download complete: ${filename}`);
224
+ resolve(dir);
225
+ });
226
+
227
+ writer.on("error", (err) => {
228
+ console.error(`Error writing file: ${err.message}`);
229
+ reject(err);
230
+ });
231
+ });
232
+
233
+ request.on("error", (err) => {
234
+ reject(new Error(`Request failed: ${err.message}`));
235
+ });
236
+
237
+ request.setTimeout(300000, () => {
238
+ request.destroy();
239
+ reject(new Error("Request timed out"));
240
+ });
241
+ });
242
+ } catch (err) {
243
+ throw new Error(`Download failed: ${err.message}`);
244
+ }
245
+ };
246
+
247
+ function exists(archive) {
248
+ if (!fs.existsSync(binDir)) {
249
+ return false;
250
+ }
251
+ return archive.bins.every((bin) => fs.existsSync(path.join(binDir, bin)));
252
+ }
253
+
254
+ export {
255
+ install,
256
+ run,
257
+ getArchive,
258
+ download,
259
+ };