@letterstory/cli 0.28.0 → 0.29.1
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 +68 -52
- package/lib/cli.mjs +55 -38
- package/lib/client.mjs +53 -0
- package/lib/commands/collection.mjs +279 -0
- package/lib/commands/collections.mjs +12 -42
- package/lib/commands/onboard.mjs +11 -11
- package/lib/commands/phantom.mjs +217 -0
- package/lib/commands/posts.mjs +22 -3
- package/lib/commands.mjs +4 -3
- package/lib/documents.mjs +141 -0
- package/package.json +1 -1
- package/lib/commands/deploy.mjs +0 -389
- package/lib/commands/phantom-job.mjs +0 -41
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// `collection` — the content engine as a primitive, on both of its doors.
|
|
2
|
+
//
|
|
3
|
+
// A collection is a publication with a one-file configuration that produces and publishes
|
|
4
|
+
// posts on a cadence; `collection run` produces ONE post on it (sourced, written, gated,
|
|
5
|
+
// published, and — when Letterstory hosts the site — rebuilt). Runs are 20–60 minutes, so
|
|
6
|
+
// `collection run` submits and then follows the run unless you tell it not to
|
|
7
|
+
// (`--no-wait`); `collection run-status` picks it up later. A direct run spends the
|
|
8
|
+
// collection's weekly cadence budget (its posts_per_week); the planner's own slots are the
|
|
9
|
+
// budget being spent as designed.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { CliError } from "../client.mjs";
|
|
13
|
+
import { flagStr, printResult, ok, toolField, followJob, argsFromFlags } from "./shared.mjs";
|
|
14
|
+
|
|
15
|
+
function readJsonFileFlag(flags, flag) {
|
|
16
|
+
const path = flagStr(flags[flag]);
|
|
17
|
+
if (path === undefined) return undefined;
|
|
18
|
+
let text;
|
|
19
|
+
try {
|
|
20
|
+
text = readFileSync(path, "utf8");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new CliError(
|
|
23
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new CliError(`--${flag} ${path} is not valid JSON.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readJsonObjectFlag(flags, name) {
|
|
33
|
+
const inline = flagStr(flags[name]);
|
|
34
|
+
if (inline !== undefined) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(inline);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new CliError(`--${name} is not valid JSON: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
42
|
+
throw new CliError(`--${name} must be a JSON object`);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
return readJsonFileFlag(flags, `${name}-file`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TERMINAL = new Set(["published", "failed", "skipped", "cancelled"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How every command in this file sources its tool inputs — see `argsFromFlags` in
|
|
52
|
+
* shared.mjs and `scripts/parity-check.ts`, which compares these keys against each tool's
|
|
53
|
+
* published input schema.
|
|
54
|
+
*/
|
|
55
|
+
export const ARGS = {
|
|
56
|
+
list_collections: {},
|
|
57
|
+
create_collection: {
|
|
58
|
+
name: { flag: "name", required: true },
|
|
59
|
+
description: "description",
|
|
60
|
+
config: { value: (flags) => readJsonObjectFlag(flags, "config") },
|
|
61
|
+
},
|
|
62
|
+
get_collection_config: {
|
|
63
|
+
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
64
|
+
},
|
|
65
|
+
set_collection_config: {
|
|
66
|
+
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
67
|
+
config: { value: (flags) => readJsonObjectFlag(flags, "config") },
|
|
68
|
+
},
|
|
69
|
+
delete_collection: {
|
|
70
|
+
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
71
|
+
},
|
|
72
|
+
run_collection: {
|
|
73
|
+
collection_id: { flag: "collection", required: true },
|
|
74
|
+
topic: "topic",
|
|
75
|
+
topic_id: { flag: "spoke" },
|
|
76
|
+
suggestion_id: { flag: "headline" },
|
|
77
|
+
source_urls: { flag: "source", type: "list" },
|
|
78
|
+
must_include: { flag: "must-include", type: "list" },
|
|
79
|
+
research_depth: { flag: "depth", enum: ["barebones", "fleshed_out"] },
|
|
80
|
+
kernel_id: { flag: "kernel" },
|
|
81
|
+
verify: { value: (flags) => (flags["no-verify"] === true ? false : undefined) },
|
|
82
|
+
shred: { flag: "shred", type: "bool" },
|
|
83
|
+
mention_degree: { flag: "mention", enum: ["off", "subtle", "woven"] },
|
|
84
|
+
approval_mode: { flag: "approval", enum: ["autopilot", "review_window", "manual"] },
|
|
85
|
+
publish: { value: (flags) => (flags["no-publish"] === true ? false : undefined) },
|
|
86
|
+
rebuild: { value: (flags) => (flags["no-rebuild"] === true ? false : undefined) },
|
|
87
|
+
schedule_for: { flag: "at" },
|
|
88
|
+
},
|
|
89
|
+
get_collection_run: {
|
|
90
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
91
|
+
},
|
|
92
|
+
list_collection_runs: {
|
|
93
|
+
collection_id: { flag: "collection" },
|
|
94
|
+
status: {
|
|
95
|
+
flag: "status",
|
|
96
|
+
enum: [
|
|
97
|
+
"scheduled",
|
|
98
|
+
"sourcing",
|
|
99
|
+
"writing",
|
|
100
|
+
"ready",
|
|
101
|
+
"publishing",
|
|
102
|
+
"published",
|
|
103
|
+
"failed",
|
|
104
|
+
"skipped",
|
|
105
|
+
"cancelled",
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
limit: { flag: "limit", type: "num" },
|
|
109
|
+
},
|
|
110
|
+
approve_collection_run: {
|
|
111
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
112
|
+
publish_now: { flag: "now", type: "bool" },
|
|
113
|
+
},
|
|
114
|
+
cancel_collection_run: {
|
|
115
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export async function cmdCollection(ctx) {
|
|
120
|
+
const sub = ctx.positionals[0];
|
|
121
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
122
|
+
switch (sub) {
|
|
123
|
+
case "list":
|
|
124
|
+
case "ls":
|
|
125
|
+
return collectionList(rest);
|
|
126
|
+
case "new":
|
|
127
|
+
case "create":
|
|
128
|
+
return collectionCreate(rest);
|
|
129
|
+
case "config":
|
|
130
|
+
return collectionConfig(rest);
|
|
131
|
+
case "delete":
|
|
132
|
+
case "rm":
|
|
133
|
+
return collectionDelete(rest);
|
|
134
|
+
case "run":
|
|
135
|
+
return collectionRun(rest);
|
|
136
|
+
case "run-status":
|
|
137
|
+
case "status":
|
|
138
|
+
return collectionRunStatus(rest);
|
|
139
|
+
case "runs":
|
|
140
|
+
return collectionRuns(rest);
|
|
141
|
+
case "approve":
|
|
142
|
+
return collectionApprove(rest);
|
|
143
|
+
case "cancel":
|
|
144
|
+
return collectionCancel(rest);
|
|
145
|
+
default:
|
|
146
|
+
throw new CliError(
|
|
147
|
+
`Unknown collection subcommand: ${sub ?? "(none)"}. Try: list, new, config, delete, run, run-status, runs, approve, cancel`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function collectionList(ctx) {
|
|
153
|
+
const result = await ctx.client.callTool("list_collections", argsFromFlags(ARGS.list_collections, ctx.flags));
|
|
154
|
+
printResult(ctx.io, ctx.flags, result);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function collectionCreate(ctx) {
|
|
159
|
+
const result = await ctx.client.callTool(
|
|
160
|
+
"create_collection",
|
|
161
|
+
argsFromFlags(ARGS.create_collection, ctx.flags, ctx.positionals)
|
|
162
|
+
);
|
|
163
|
+
ok(
|
|
164
|
+
ctx,
|
|
165
|
+
`Collection ${toolField(result, "collection.name") ?? ""} created (${toolField(result, "collection.collection_id") ?? "?"}).`
|
|
166
|
+
);
|
|
167
|
+
printResult(ctx.io, ctx.flags, result);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// `collection config <id>` prints the one file; `--config '<json>'` or `--config-file cfg.json` patches it.
|
|
172
|
+
async function collectionConfig(ctx) {
|
|
173
|
+
const { client, flags, io, positionals } = ctx;
|
|
174
|
+
const patch = readJsonObjectFlag(flags, "config");
|
|
175
|
+
if (patch === undefined) {
|
|
176
|
+
const result = await client.callTool(
|
|
177
|
+
"get_collection_config",
|
|
178
|
+
argsFromFlags(ARGS.get_collection_config, flags, positionals)
|
|
179
|
+
);
|
|
180
|
+
printResult(io, flags, result);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const result = await client.callTool(
|
|
184
|
+
"set_collection_config",
|
|
185
|
+
argsFromFlags(ARGS.set_collection_config, flags, positionals)
|
|
186
|
+
);
|
|
187
|
+
ok(ctx, `Applied: ${(toolField(result, "applied") ?? []).join(", ") || "nothing"}.`);
|
|
188
|
+
printResult(io, flags, result);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function collectionDelete(ctx) {
|
|
193
|
+
if (ctx.flags.yes !== true) {
|
|
194
|
+
ctx.io.error("Refusing to delete without --yes.");
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
const result = await ctx.client.callTool(
|
|
198
|
+
"delete_collection",
|
|
199
|
+
argsFromFlags(ARGS.delete_collection, ctx.flags, ctx.positionals)
|
|
200
|
+
);
|
|
201
|
+
printResult(ctx.io, ctx.flags, result);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function collectionRun(ctx) {
|
|
206
|
+
const { client, flags, io, bin } = ctx;
|
|
207
|
+
const args = argsFromFlags(ARGS.run_collection, flags, ctx.positionals);
|
|
208
|
+
const result = await client.callTool("run_collection", args);
|
|
209
|
+
const runId = toolField(result, "run_id");
|
|
210
|
+
if (flags["no-wait"] === true || !runId) {
|
|
211
|
+
ok(ctx, `Run started as ${runId ?? "?"}. Follow it with: ${bin} collection run-status ${runId ?? "<run-id>"}`);
|
|
212
|
+
printResult(io, flags, result);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
io.log(`Run ${runId} started — following (20–60 min; Ctrl-C and use run-status to pick it up later)…`);
|
|
216
|
+
let lastLine = "";
|
|
217
|
+
const followed = await followJob(
|
|
218
|
+
io,
|
|
219
|
+
async () => {
|
|
220
|
+
const r = await client.callTool("get_collection_run", { run_id: runId });
|
|
221
|
+
const run = toolField(r, "run") ?? r;
|
|
222
|
+
const line = `${run.status}${run.phase ? ` · ${run.phase}` : ""}${run.phase_detail ? ` — ${run.phase_detail}` : ""}`;
|
|
223
|
+
if (line !== lastLine) {
|
|
224
|
+
io.log(` … ${line}`);
|
|
225
|
+
lastLine = line;
|
|
226
|
+
}
|
|
227
|
+
if (run.status === "ready" && run.approval?.mode !== "autopilot" && !run.approval?.approved_at)
|
|
228
|
+
return { done: true, value: run };
|
|
229
|
+
return { done: TERMINAL.has(run.status), value: run };
|
|
230
|
+
},
|
|
231
|
+
{ intervalMs: 30000, ceilingMs: 90 * 60 * 1000 }
|
|
232
|
+
);
|
|
233
|
+
const run = followed.value;
|
|
234
|
+
if (run?.status === "ready") {
|
|
235
|
+
ok(ctx, `Written and waiting for approval. Approve with: ${bin} collection approve ${runId} [--now]`);
|
|
236
|
+
} else if (!followed.settled) {
|
|
237
|
+
io.log(`Still running. Pick it up with: ${bin} collection run-status ${runId}`);
|
|
238
|
+
}
|
|
239
|
+
printResult(io, flags, run ?? result);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function collectionRunStatus(ctx) {
|
|
244
|
+
const result = await ctx.client.callTool(
|
|
245
|
+
"get_collection_run",
|
|
246
|
+
argsFromFlags(ARGS.get_collection_run, ctx.flags, ctx.positionals)
|
|
247
|
+
);
|
|
248
|
+
printResult(ctx.io, ctx.flags, result);
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function collectionRuns(ctx) {
|
|
253
|
+
const result = await ctx.client.callTool(
|
|
254
|
+
"list_collection_runs",
|
|
255
|
+
argsFromFlags(ARGS.list_collection_runs, ctx.flags)
|
|
256
|
+
);
|
|
257
|
+
printResult(ctx.io, ctx.flags, result);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function collectionApprove(ctx) {
|
|
262
|
+
const result = await ctx.client.callTool(
|
|
263
|
+
"approve_collection_run",
|
|
264
|
+
argsFromFlags(ARGS.approve_collection_run, ctx.flags, ctx.positionals)
|
|
265
|
+
);
|
|
266
|
+
ok(ctx, `Approved${ctx.flags.now === true ? " — publishing now" : ""}.`);
|
|
267
|
+
printResult(ctx.io, ctx.flags, result);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function collectionCancel(ctx) {
|
|
272
|
+
const result = await ctx.client.callTool(
|
|
273
|
+
"cancel_collection_run",
|
|
274
|
+
argsFromFlags(ARGS.cancel_collection_run, ctx.flags, ctx.positionals)
|
|
275
|
+
);
|
|
276
|
+
ok(ctx, toolField(result, "cancelled") ? "Cancelled." : "Nothing was in flight.");
|
|
277
|
+
printResult(ctx.io, ctx.flags, result);
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { CliError } from "../client.mjs";
|
|
5
5
|
import { flagStr, printResult, ok, argsFromFlags } from "./shared.mjs";
|
|
6
|
+
import { cmdCollection } from "./collection.mjs";
|
|
6
7
|
|
|
7
8
|
// `--flag none|off|""` clears a nullable field; the server takes null for that.
|
|
8
9
|
const clearable = (flag) => ({
|
|
@@ -21,11 +22,6 @@ const stringBool = (flag) => ({
|
|
|
21
22
|
// How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
|
|
22
23
|
// `scripts/parity-check.ts` compares these keys against each tool's published input schema.
|
|
23
24
|
export const ARGS = {
|
|
24
|
-
list_collections: {},
|
|
25
|
-
create_collection: {
|
|
26
|
-
name: { flag: "name", required: true },
|
|
27
|
-
description: "description",
|
|
28
|
-
},
|
|
29
25
|
update_collection: {
|
|
30
26
|
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
31
27
|
name: "name",
|
|
@@ -35,9 +31,6 @@ export const ARGS = {
|
|
|
35
31
|
cover_canvas_type_id: clearable("cover-canvas-type-id"),
|
|
36
32
|
seo_market: "seo-market",
|
|
37
33
|
},
|
|
38
|
-
delete_collection: {
|
|
39
|
-
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
40
|
-
},
|
|
41
34
|
assign_article_to_collection: {
|
|
42
35
|
article_id: { positional: 0, label: "article-id", required: true },
|
|
43
36
|
collection_id: { positional: 1, label: "collection-id", required: true },
|
|
@@ -83,17 +76,23 @@ export async function cmdCollections(ctx) {
|
|
|
83
76
|
const sub = ctx.positionals[0];
|
|
84
77
|
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
85
78
|
switch (sub) {
|
|
79
|
+
// The primitive's own commands live in collection.mjs (one door: `collection run`);
|
|
80
|
+
// `collections` keeps the legacy verbs and forwards the shared ones.
|
|
86
81
|
case "list":
|
|
87
82
|
case "ls":
|
|
88
|
-
return collectionsList(rest);
|
|
89
83
|
case "new":
|
|
90
84
|
case "create":
|
|
91
|
-
return collectionsNew(rest);
|
|
92
|
-
case "update":
|
|
93
|
-
return collectionsUpdate(rest);
|
|
94
85
|
case "delete":
|
|
95
86
|
case "rm":
|
|
96
|
-
|
|
87
|
+
case "config":
|
|
88
|
+
case "run":
|
|
89
|
+
case "run-status":
|
|
90
|
+
case "runs":
|
|
91
|
+
case "approve":
|
|
92
|
+
case "cancel":
|
|
93
|
+
return cmdCollection(ctx);
|
|
94
|
+
case "update":
|
|
95
|
+
return collectionsUpdate(rest);
|
|
97
96
|
case "assign":
|
|
98
97
|
return collectionsAssign(rest);
|
|
99
98
|
case "autopen":
|
|
@@ -130,22 +129,6 @@ async function collectionsActivate(ctx) {
|
|
|
130
129
|
return 0;
|
|
131
130
|
}
|
|
132
131
|
|
|
133
|
-
async function collectionsList(ctx) {
|
|
134
|
-
const { client, flags, io } = ctx;
|
|
135
|
-
const result = await client.callTool("list_collections", {});
|
|
136
|
-
printResult(io, flags, result);
|
|
137
|
-
return 0;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function collectionsNew(ctx) {
|
|
141
|
-
const { client, flags, io } = ctx;
|
|
142
|
-
const args = argsFromFlags(ARGS.create_collection, flags);
|
|
143
|
-
const result = await client.callTool("create_collection", args);
|
|
144
|
-
ok(ctx, `Created collection "${args.name}".`);
|
|
145
|
-
printResult(io, flags, result);
|
|
146
|
-
return 0;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
132
|
async function collectionsUpdate(ctx) {
|
|
150
133
|
const { client, positionals, flags, io } = ctx;
|
|
151
134
|
const patch = argsFromFlags(ARGS.update_collection, flags, positionals);
|
|
@@ -160,19 +143,6 @@ async function collectionsUpdate(ctx) {
|
|
|
160
143
|
return 0;
|
|
161
144
|
}
|
|
162
145
|
|
|
163
|
-
async function collectionsDelete(ctx) {
|
|
164
|
-
const { client, positionals, flags, io } = ctx;
|
|
165
|
-
const args = argsFromFlags(ARGS.delete_collection, flags, positionals);
|
|
166
|
-
if (!flags.yes) {
|
|
167
|
-
io.error(`Re-run with --yes to confirm: ${ctx.bin} collections delete ${args.collection_id} --yes`);
|
|
168
|
-
return 1;
|
|
169
|
-
}
|
|
170
|
-
const result = await client.callTool("delete_collection", args);
|
|
171
|
-
ok(ctx, "Collection deleted.");
|
|
172
|
-
printResult(io, flags, result);
|
|
173
|
-
return 0;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
146
|
async function collectionsAssign(ctx) {
|
|
177
147
|
const { client, positionals, flags, io } = ctx;
|
|
178
148
|
const result = await client.callTool(
|
package/lib/commands/onboard.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// `onboard` — the one-shot phantom onboarding recipe: a client domain in, a live phantom
|
|
2
|
-
// blog with its first post(s) out. It drives ONE server tool, `
|
|
2
|
+
// blog with its first post(s) out. It drives ONE server tool, `plan_phantom`:
|
|
3
3
|
// - a bare `onboard <domain>` is a safe DRY RUN (accept:false) — it prints the proposed
|
|
4
4
|
// plan (masthead name, description, strategy, cadence/cover/seed defaults) and stops.
|
|
5
|
-
// - `--accept` (or any override flag) APPLIES: `
|
|
5
|
+
// - `--accept` (or any override flag) APPLIES: `plan_phantom{accept:true}` starts a
|
|
6
6
|
// durable onboarding job server-side and returns a job_id; this command then polls
|
|
7
|
-
// `
|
|
7
|
+
// `get_phantom_launch_status` until the job is done (provision → configure → seed).
|
|
8
8
|
//
|
|
9
9
|
// `--about "<the beat>"` is the highest-leverage flag: it becomes the publication's planner
|
|
10
10
|
// steering prompt, so it shapes the topic map and every post the site ever writes — not just
|
|
@@ -120,7 +120,7 @@ export const ARGS = {
|
|
|
120
120
|
get_domain_onboard_status: {
|
|
121
121
|
job_id: "attach",
|
|
122
122
|
},
|
|
123
|
-
|
|
123
|
+
plan_phantom: {
|
|
124
124
|
domain: { positional: 0, label: "domain", required: true },
|
|
125
125
|
// --accept gates the two-call flow below (dry run, then apply) rather than being
|
|
126
126
|
// forwarded: the first call never sends it, the second always sends true.
|
|
@@ -152,8 +152,8 @@ export const ARGS = {
|
|
|
152
152
|
},
|
|
153
153
|
},
|
|
154
154
|
},
|
|
155
|
-
|
|
156
|
-
job_id: { internal: "the job id
|
|
155
|
+
get_phantom_launch_status: {
|
|
156
|
+
job_id: { internal: "the job id plan_phantom just returned" },
|
|
157
157
|
},
|
|
158
158
|
propose_phantom_plan: {
|
|
159
159
|
client_domain: { positional: 0, label: "client-domain" },
|
|
@@ -372,7 +372,7 @@ async function cmdOnboardMastheads(ctx) {
|
|
|
372
372
|
function overridesFromFlags(flags) {
|
|
373
373
|
// A chosen topic plan pins direction + pillars; an explicit --about still wins on direction.
|
|
374
374
|
const plan = readTopicPlan(flags);
|
|
375
|
-
const overrides = argsFromFlags(ARGS.
|
|
375
|
+
const overrides = argsFromFlags(ARGS.plan_phantom.overrides.object, flags, [], { plan });
|
|
376
376
|
// buy_now spends real money against a 2-per-org cap; refusing the mismatch here beats
|
|
377
377
|
// silently downgrading it to a subdomain launch the caller didn't ask for.
|
|
378
378
|
if (overrides.domain_mode && overrides.domain_mode !== "subdomain" && !overrides.site_domain) {
|
|
@@ -768,7 +768,7 @@ export async function cmdOnboard(ctx) {
|
|
|
768
768
|
if (!reviewedPlan) {
|
|
769
769
|
// Step 1: dry-run plan (accept defaults to false server-side). Persists nothing.
|
|
770
770
|
const dryRun = await client.callTool(
|
|
771
|
-
"
|
|
771
|
+
"plan_phantom",
|
|
772
772
|
compact({ domain, overrides: Object.keys(overrides).length ? overrides : undefined })
|
|
773
773
|
);
|
|
774
774
|
ok(ctx, "Onboarding plan:");
|
|
@@ -801,7 +801,7 @@ export async function cmdOnboard(ctx) {
|
|
|
801
801
|
// Step 3: apply — start the durable onboarding job.
|
|
802
802
|
ok(ctx, "Applying — starting the onboarding job…");
|
|
803
803
|
const started = await client.callTool(
|
|
804
|
-
"
|
|
804
|
+
"plan_phantom",
|
|
805
805
|
compact({
|
|
806
806
|
domain,
|
|
807
807
|
accept: true,
|
|
@@ -810,14 +810,14 @@ export async function cmdOnboard(ctx) {
|
|
|
810
810
|
})
|
|
811
811
|
);
|
|
812
812
|
const jobId = started.job_id;
|
|
813
|
-
if (!jobId) throw new CliError("
|
|
813
|
+
if (!jobId) throw new CliError("plan_phantom did not return a job id.");
|
|
814
814
|
|
|
815
815
|
// Step 4: poll the job to a terminal state, printing stage transitions.
|
|
816
816
|
let lastStage = null;
|
|
817
817
|
const final = await pollUntil(
|
|
818
818
|
io,
|
|
819
819
|
async () => {
|
|
820
|
-
const s = await client.callTool("
|
|
820
|
+
const s = await client.callTool("get_phantom_launch_status", { job_id: jobId });
|
|
821
821
|
if (s.status === "failed")
|
|
822
822
|
throw new CliError(`Onboarding failed at stage "${s.stage}": ${s.error ?? "unknown error"}`);
|
|
823
823
|
if (s.stage !== lastStage) {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// `phantom` — Phantomstory: a collection Letterstory hosts as an independent publication.
|
|
2
|
+
//
|
|
3
|
+
// Hosting only: the site, its identity and theme, its domain, its rebuilds, its balance.
|
|
4
|
+
// Publishing a post to it is `collection run --collection <its collection_id>`; its
|
|
5
|
+
// editorial file is `collection config <its collection_id>` (or `phantom config <id>`,
|
|
6
|
+
// which shows both joined). Phantoms are metered in SITES live at once (`phantom balance`),
|
|
7
|
+
// not runs.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { CliError } from "../client.mjs";
|
|
11
|
+
import { flagStr, requirePositional, printResult, ok, toolField, followJob, argsFromFlags } from "./shared.mjs";
|
|
12
|
+
|
|
13
|
+
function readJsonFileFlag(flags, flag) {
|
|
14
|
+
const path = flagStr(flags[flag]);
|
|
15
|
+
if (path === undefined) return undefined;
|
|
16
|
+
let text;
|
|
17
|
+
try {
|
|
18
|
+
text = readFileSync(path, "utf8");
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new CliError(
|
|
21
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(text);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new CliError(`--${flag} ${path} is not valid JSON.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function readJsonObjectFlag(flags, name) {
|
|
31
|
+
const inline = flagStr(flags[name]);
|
|
32
|
+
if (inline !== undefined) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(inline);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new CliError(`--${name} is not valid JSON: ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
40
|
+
throw new CliError(`--${name} must be a JSON object`);
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
return readJsonFileFlag(flags, `${name}-file`);
|
|
44
|
+
}
|
|
45
|
+
function stripScheme(domain) {
|
|
46
|
+
return String(domain)
|
|
47
|
+
.replace(/^https?:\/\//i, "")
|
|
48
|
+
.replace(/\/+$/, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const ARGS = {
|
|
52
|
+
list_phantoms: { limit: { flag: "limit", type: "num" } },
|
|
53
|
+
get_phantom: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
|
|
54
|
+
create_phantom: {
|
|
55
|
+
name: { flag: "name", required: true },
|
|
56
|
+
description: "description",
|
|
57
|
+
theme: "theme",
|
|
58
|
+
collection_id: { flag: "collection" },
|
|
59
|
+
customer_ref: { flag: "customer-ref" },
|
|
60
|
+
configure: { value: (flags) => (flags["no-configure"] === true ? false : undefined) },
|
|
61
|
+
config: { value: (flags) => readJsonObjectFlag(flags, "config") },
|
|
62
|
+
client_domain: { flag: "client-domain" },
|
|
63
|
+
dry_run: { flag: "dry-run", type: "bool" },
|
|
64
|
+
},
|
|
65
|
+
get_phantom_config: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
|
|
66
|
+
set_phantom_config: {
|
|
67
|
+
phantom_id: { positional: 0, label: "phantom-id", required: true },
|
|
68
|
+
config: {
|
|
69
|
+
object: {
|
|
70
|
+
name: "name",
|
|
71
|
+
description: "description",
|
|
72
|
+
theme: "theme",
|
|
73
|
+
customer_ref: { flag: "customer-ref" },
|
|
74
|
+
collection_id: { flag: "collection" },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
collection: { value: (flags) => readJsonObjectFlag(flags, "collection-config") },
|
|
78
|
+
configure: { flag: "configure", type: "bool" },
|
|
79
|
+
dry_run: { flag: "dry-run", type: "bool" },
|
|
80
|
+
},
|
|
81
|
+
delete_phantom: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
|
|
82
|
+
rebuild_phantom: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
|
|
83
|
+
get_phantom_diagnostics: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
|
|
84
|
+
get_phantom_balance: {},
|
|
85
|
+
check_domain: {
|
|
86
|
+
domain: { value: (flags, positionals) => stripScheme(requirePositional(positionals, 0, "domain")) },
|
|
87
|
+
},
|
|
88
|
+
buy_domain: {
|
|
89
|
+
phantom_id: { positional: 0, label: "phantom-id", required: true },
|
|
90
|
+
domain: { value: (flags, positionals) => stripScheme(requirePositional(positionals, 1, "domain")) },
|
|
91
|
+
},
|
|
92
|
+
connect_domain: {
|
|
93
|
+
phantom_id: { positional: 0, label: "phantom-id", required: true },
|
|
94
|
+
domain: { value: (flags, positionals) => stripScheme(requirePositional(positionals, 1, "domain")) },
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export async function cmdPhantom(ctx) {
|
|
99
|
+
const sub = ctx.positionals[0];
|
|
100
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
101
|
+
switch (sub) {
|
|
102
|
+
case "list":
|
|
103
|
+
case "ls":
|
|
104
|
+
return call(rest, "list_phantoms");
|
|
105
|
+
case "get":
|
|
106
|
+
case "show":
|
|
107
|
+
case "status":
|
|
108
|
+
return call(rest, "get_phantom");
|
|
109
|
+
case "new":
|
|
110
|
+
case "create":
|
|
111
|
+
return phantomCreate(rest);
|
|
112
|
+
case "config":
|
|
113
|
+
return phantomConfig(rest);
|
|
114
|
+
case "delete":
|
|
115
|
+
case "rm":
|
|
116
|
+
if (rest.flags.yes !== true) {
|
|
117
|
+
rest.io.error("Refusing to tear down without --yes.");
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
return call(rest, "delete_phantom");
|
|
121
|
+
case "rebuild":
|
|
122
|
+
return call(rest, "rebuild_phantom");
|
|
123
|
+
case "diagnostics":
|
|
124
|
+
case "diag":
|
|
125
|
+
return call(rest, "get_phantom_diagnostics");
|
|
126
|
+
case "balance":
|
|
127
|
+
return call(rest, "get_phantom_balance");
|
|
128
|
+
case "domain":
|
|
129
|
+
return phantomDomain(rest);
|
|
130
|
+
default:
|
|
131
|
+
throw new CliError(
|
|
132
|
+
`Unknown phantom subcommand: ${sub ?? "(none)"}. Try: list, get, new, config, delete, rebuild, diagnostics, balance, domain`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function call(ctx, tool) {
|
|
138
|
+
const result = await ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
|
|
139
|
+
printResult(ctx.io, ctx.flags, result);
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function phantomCreate(ctx) {
|
|
144
|
+
const { client, flags, io, bin } = ctx;
|
|
145
|
+
const result = await client.callTool("create_phantom", argsFromFlags(ARGS.create_phantom, flags));
|
|
146
|
+
if (toolField(result, "dry_run")) {
|
|
147
|
+
printResult(io, flags, result);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
const id = toolField(result, "phantom")?.phantom_id ?? toolField(result, "phantom_id");
|
|
151
|
+
if (flags["no-wait"] === true || !id) {
|
|
152
|
+
ok(ctx, `Phantom ${id ?? "?"} provisioning. Poll with: ${bin} phantom get ${id ?? "<phantom-id>"}`);
|
|
153
|
+
printResult(io, flags, result);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
io.log(`Phantom ${id} provisioning — following…`);
|
|
157
|
+
let last = "";
|
|
158
|
+
const followed = await followJob(
|
|
159
|
+
io,
|
|
160
|
+
async () => {
|
|
161
|
+
const r = await client.callTool("get_phantom", { phantom_id: id });
|
|
162
|
+
const p = toolField(r, "phantom") ?? r;
|
|
163
|
+
const line = `${p.status}${p.phase ? ` · ${p.phase}` : ""}`;
|
|
164
|
+
if (line !== last) {
|
|
165
|
+
io.log(` … ${line}`);
|
|
166
|
+
last = line;
|
|
167
|
+
}
|
|
168
|
+
return { done: p.status === "live" || p.status === "error", value: p };
|
|
169
|
+
},
|
|
170
|
+
{ intervalMs: 5000, ceilingMs: 10 * 60 * 1000 }
|
|
171
|
+
);
|
|
172
|
+
const p = followed.value;
|
|
173
|
+
if (p?.status === "live")
|
|
174
|
+
ok(ctx, `Live at ${p.url}. Configuration continues in the background: ${bin} phantom config ${id}`);
|
|
175
|
+
else if (p?.status === "error") io.error(`Provisioning failed: ${p.last_error ?? "unknown"}`);
|
|
176
|
+
else io.log(`Still provisioning. Poll with: ${bin} phantom get ${id}`);
|
|
177
|
+
printResult(io, flags, p ?? result);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// `phantom config <id>` prints the joined file; any patch flag, --collection-config, or --configure applies.
|
|
182
|
+
async function phantomConfig(ctx) {
|
|
183
|
+
const { client, flags, io, positionals } = ctx;
|
|
184
|
+
const args = argsFromFlags(ARGS.set_phantom_config, flags, positionals);
|
|
185
|
+
const patching = args.config !== undefined || args.collection !== undefined || args.configure !== undefined;
|
|
186
|
+
if (!patching) {
|
|
187
|
+
const result = await client.callTool(
|
|
188
|
+
"get_phantom_config",
|
|
189
|
+
argsFromFlags(ARGS.get_phantom_config, flags, positionals)
|
|
190
|
+
);
|
|
191
|
+
printResult(io, flags, result);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const result = await client.callTool("set_phantom_config", args);
|
|
195
|
+
ok(ctx, `Applied: ${(toolField(result, "applied") ?? []).join(", ") || "nothing (dry run)"}.`);
|
|
196
|
+
printResult(io, flags, result);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function phantomDomain(ctx) {
|
|
201
|
+
const sub = ctx.positionals[0];
|
|
202
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
203
|
+
switch (sub) {
|
|
204
|
+
case "check":
|
|
205
|
+
return call(rest, "check_domain");
|
|
206
|
+
case "buy":
|
|
207
|
+
if (rest.flags.yes !== true) {
|
|
208
|
+
rest.io.error("Buying a domain spends real money. Re-run with --yes.");
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
return call(rest, "buy_domain");
|
|
212
|
+
case "connect":
|
|
213
|
+
return call(rest, "connect_domain");
|
|
214
|
+
default:
|
|
215
|
+
throw new CliError(`Unknown phantom domain subcommand: ${sub ?? "(none)"}. Try: check, buy, connect`);
|
|
216
|
+
}
|
|
217
|
+
}
|