@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
package/lib/commands/posts.mjs
CHANGED
|
@@ -20,6 +20,9 @@ export const ARGS = {
|
|
|
20
20
|
publish_article: {
|
|
21
21
|
article_id: { positional: 0, label: "article-id", required: true },
|
|
22
22
|
},
|
|
23
|
+
delete_article: {
|
|
24
|
+
article_ids: { flag: "id", type: "list", required: true, label: "id" },
|
|
25
|
+
},
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
export async function cmdPosts(ctx) {
|
|
@@ -37,9 +40,12 @@ export async function cmdPosts(ctx) {
|
|
|
37
40
|
return postsPublish(rest);
|
|
38
41
|
case "unpublish":
|
|
39
42
|
return postsUnpublish(rest);
|
|
43
|
+
case "delete":
|
|
44
|
+
case "rm":
|
|
45
|
+
return postsDelete(rest);
|
|
40
46
|
default:
|
|
41
47
|
throw new CliError(
|
|
42
|
-
`Unknown posts subcommand: ${sub ?? "(none)"}. Try: list, show, new, publish, unpublish`
|
|
48
|
+
`Unknown posts subcommand: ${sub ?? "(none)"}. Try: list, show, new, publish, unpublish, delete`
|
|
43
49
|
);
|
|
44
50
|
}
|
|
45
51
|
}
|
|
@@ -66,8 +72,10 @@ async function postsNew(ctx) {
|
|
|
66
72
|
const collection = requireFlag(flags, "collection");
|
|
67
73
|
const body = readBodyInput(flags);
|
|
68
74
|
if (!body) throw new CliError("Provide --body or --file <path>.");
|
|
69
|
-
const
|
|
70
|
-
|
|
75
|
+
const articleId = flagStr(flags["article-id"]);
|
|
76
|
+
const args = compact({ title, body, collection_id: collection, article_id: articleId });
|
|
77
|
+
const result = await client.callTool("ingest_article", args);
|
|
78
|
+
ok(ctx, articleId ? `Updated "${title}".` : `Created draft "${title}".`);
|
|
71
79
|
printResult(io, flags, result);
|
|
72
80
|
return 0;
|
|
73
81
|
}
|
|
@@ -89,6 +97,17 @@ async function postsUnpublish(ctx) {
|
|
|
89
97
|
return 0;
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
// Permanent: no confirmation prompt here — the CLI is a scripting surface, and the
|
|
101
|
+
// tool result reports exactly which ids were deleted vs not found.
|
|
102
|
+
async function postsDelete(ctx) {
|
|
103
|
+
const { client, flags, io, positionals } = ctx;
|
|
104
|
+
const args = argsFromFlags(ARGS.delete_article, flags, positionals);
|
|
105
|
+
const result = await client.callTool("delete_article", args);
|
|
106
|
+
ok(ctx, `Deleted ${result?.deleted_ids?.length ?? 0} article(s).`);
|
|
107
|
+
printResult(io, flags, result);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
92
111
|
// --- published (frozen public view) -----------------------------------------
|
|
93
112
|
|
|
94
113
|
export async function cmdPublished(ctx) {
|
package/lib/commands.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Command handlers, split into focused modules under ./commands/*.mjs as the surface
|
|
2
|
-
// grew past a single-file's worth of clarity (auth, discovery,
|
|
2
|
+
// grew past a single-file's worth of clarity (auth, discovery, collection/phantom, posts,
|
|
3
3
|
// collections, flows, connectors, strategy, insights). This file re-exports
|
|
4
4
|
// everything so `cli.mjs` — and any test importing straight from "commands.mjs" —
|
|
5
5
|
// keep a single, stable import path regardless of how the implementation is split.
|
|
6
6
|
|
|
7
7
|
export * from "./commands/auth.mjs";
|
|
8
8
|
export * from "./commands/discovery.mjs";
|
|
9
|
-
export * from "./commands/
|
|
9
|
+
export * from "./commands/collection.mjs";
|
|
10
|
+
export * from "./commands/phantom.mjs";
|
|
10
11
|
export * from "./commands/mcp.mjs";
|
|
11
12
|
export * from "./commands/posts.mjs";
|
|
12
13
|
export * from "./commands/reviewers.mjs";
|
|
@@ -24,7 +25,7 @@ export * from "./commands/templates.mjs";
|
|
|
24
25
|
export * from "./commands/autopen.mjs";
|
|
25
26
|
export * from "./commands/kernel.mjs";
|
|
26
27
|
export * from "./commands/lede.mjs";
|
|
27
|
-
|
|
28
|
+
|
|
28
29
|
export * from "./commands/onboard.mjs";
|
|
29
30
|
export * from "./commands/seers.mjs";
|
|
30
31
|
export * from "./commands/shredder.mjs";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Document `--file` flags, resolved through the app's extraction door before any command runs.
|
|
2
|
+
//
|
|
3
|
+
// WHY THE CLI DOES NOT PARSE ANYTHING ITSELF. `--file` used to be `readFileSync(path, "utf8")`,
|
|
4
|
+
// full stop. That is correct for a .md or .txt and silently wrong for everything else: a
|
|
5
|
+
// `letterstory kernel write --file report.docx` sent the zip's raw bytes as the article input,
|
|
6
|
+
// so decompose was handed `PK\x03\x04…` and asked to find a thesis in it. A .pdf was the same.
|
|
7
|
+
// No error, no warning, just a run spent on noise.
|
|
8
|
+
//
|
|
9
|
+
// The app already had a real extractor (mammoth, pdf-parse, and the rest — src/lib/uploads),
|
|
10
|
+
// so the fix is to USE it rather than grow a second one here: the bytes go to
|
|
11
|
+
// `POST /api/documents/extract` and come back as text. One engine, one format list, one
|
|
12
|
+
// refusal message, whether the document arrived from the Studio's dropzone, an MCP tool or
|
|
13
|
+
// this flag. A CLI-local parser would have been the same mistake the browser copy was.
|
|
14
|
+
//
|
|
15
|
+
// Formats we can read without help (.md, .txt, …) are still read locally — no round trip for
|
|
16
|
+
// the common case, and `letterstory` keeps working against a file when the network does not.
|
|
17
|
+
//
|
|
18
|
+
// And only formats the door actually reads are touched at all: `--file` means different things
|
|
19
|
+
// to different commands (see NEEDS_EXTRACTION), so anything else is passed through untouched.
|
|
20
|
+
|
|
21
|
+
import { readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { basename, extname } from "node:path";
|
|
23
|
+
import { CliError } from "./client.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extensions whose bytes a model CANNOT use as they stand — a zip (docx, odt), a binary
|
|
27
|
+
* container (pdf), or control-word soup (rtf). Only these go to the extraction door.
|
|
28
|
+
*
|
|
29
|
+
* NARROW ON PURPOSE, twice over.
|
|
30
|
+
*
|
|
31
|
+
* It is an ALLOWLIST rather than "anything not plain text", because `--file` is OVERLOADED
|
|
32
|
+
* across the CLI: a prose body for `kernel write`, a JSON manifest for `seers apply`, an
|
|
33
|
+
* output DESTINATION for `seers export`. Resolving every non-plain-text `--file` sent
|
|
34
|
+
* `seers export --file seers.json` to the door and broke a command that was only ever going
|
|
35
|
+
* to WRITE that path.
|
|
36
|
+
*
|
|
37
|
+
* And it EXCLUDES html/markdown/csv/captions even though the door can read them, because
|
|
38
|
+
* those already are what the primitives take. `run_shred` accepts HTML and returns it with
|
|
39
|
+
* its images, embeds, links and attributes exactly as they arrived — extracting first would
|
|
40
|
+
* strip the scripts, styles and comments the shredder was asked to preserve, and
|
|
41
|
+
* `cli-shred-kernel-e2e` asserts those bytes cross the wire untouched. Passing them through
|
|
42
|
+
* is both lossless and what shipped.
|
|
43
|
+
*
|
|
44
|
+
* Kept honest by `__tests__/unit/lib/uploads/surface-parity.test.ts`, which asserts this set
|
|
45
|
+
* equals the registry's `plainText: false` rows rather than trusting them to stay aligned.
|
|
46
|
+
*/
|
|
47
|
+
const NEEDS_EXTRACTION = new Set(["docx", "pdf", "odt", "rtf"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `(inlineFlag, fileFlag)` pairs `readTextInput` reads, which is what makes this
|
|
51
|
+
* interception possible without touching 75 `argsFromFlags` call sites: the inline value
|
|
52
|
+
* WINS over the path, so resolving a document means setting the inline flag to its text and
|
|
53
|
+
* leaving the path alone.
|
|
54
|
+
*
|
|
55
|
+
* Adding a new `--x-file` flag means adding its pair here. The CLI parity test asserts that
|
|
56
|
+
* every `readTextInput` call in the tree has a row.
|
|
57
|
+
*/
|
|
58
|
+
export const DOCUMENT_FLAG_PAIRS = [
|
|
59
|
+
["body", "file"],
|
|
60
|
+
["brief", "brief-file"],
|
|
61
|
+
["text", "text-file"],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `readTextInput` pairs that are STRUCTURED ARGUMENTS, not documents — JSON a command parses
|
|
66
|
+
* immediately, so extraction must never touch them (a .json is not a document format, and
|
|
67
|
+
* routing one through the door would refuse it outright).
|
|
68
|
+
*
|
|
69
|
+
* Declared rather than merely omitted so the choice is a decision somebody made: the parity
|
|
70
|
+
* test asserts every `readTextInput` pair in the CLI appears in exactly ONE of these two
|
|
71
|
+
* lists, so a new `--x-file` flag cannot be added without answering "prose or JSON?".
|
|
72
|
+
*/
|
|
73
|
+
export const JSON_ARG_FLAG_PAIRS = [
|
|
74
|
+
// A topic plan, or a whole saved proposal (onboard.mjs readTopicPlan).
|
|
75
|
+
["plan", "plan-file"],
|
|
76
|
+
// A reviewed plan handed back to be applied verbatim (onboard.mjs readAcceptedPlan).
|
|
77
|
+
["accept-plan-json", "accept-plan"],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** Lower-case extension without the dot. */
|
|
81
|
+
function extOf(path) {
|
|
82
|
+
return extname(path).replace(/^\./, "").toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extract a document's text by posting it to the app's door.
|
|
87
|
+
*
|
|
88
|
+
* Uses the same authenticated client every other command uses, so an expired session or a
|
|
89
|
+
* missing key fails the way it does everywhere else rather than as an opaque upload error.
|
|
90
|
+
*/
|
|
91
|
+
async function extractViaDoor(client, path) {
|
|
92
|
+
let bytes;
|
|
93
|
+
try {
|
|
94
|
+
bytes = readFileSync(path);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new CliError(`Could not read ${path}: ${error.message}`);
|
|
97
|
+
}
|
|
98
|
+
const form = new FormData();
|
|
99
|
+
form.append("file", new Blob([bytes]), basename(path));
|
|
100
|
+
|
|
101
|
+
const result = await client.postForm("/api/documents/extract", form);
|
|
102
|
+
if (typeof result?.text !== "string" || !result.text.trim()) {
|
|
103
|
+
throw new CliError(`${basename(path)} had no readable text.`);
|
|
104
|
+
}
|
|
105
|
+
// Worth saying out loud: a Google Docs export's images are gone, and a silent removal is
|
|
106
|
+
// how someone wonders later why the article came back without its figure.
|
|
107
|
+
if (result.notice) process.stderr.write(`${basename(path)}: ${result.notice}\n`);
|
|
108
|
+
return result.text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve every document `--*-file` flag in `flags` to text, in place.
|
|
113
|
+
*
|
|
114
|
+
* Plain-text formats are read locally; everything else goes through the door. A flag whose
|
|
115
|
+
* inline twin is already set is left alone — an explicit `--body` beats a `--file`, which is
|
|
116
|
+
* the precedence `readTextInput` has always had.
|
|
117
|
+
*
|
|
118
|
+
* `-` (stdin) is deliberately NOT handled here: it is always text by definition, and
|
|
119
|
+
* `readTextInput` already reads it.
|
|
120
|
+
*/
|
|
121
|
+
export async function resolveDocumentFlags(client, flags) {
|
|
122
|
+
for (const [inlineFlag, fileFlag] of DOCUMENT_FLAG_PAIRS) {
|
|
123
|
+
const path = flags[fileFlag];
|
|
124
|
+
if (typeof path !== "string" || !path || path === "-") continue;
|
|
125
|
+
// An explicit inline value wins; nothing to resolve.
|
|
126
|
+
if (flags[inlineFlag] !== undefined) continue;
|
|
127
|
+
// Not a format the door reads — leave the flag completely alone, whatever the command
|
|
128
|
+
// means by it (a JSON manifest, an output path, plain text readTextInput handles).
|
|
129
|
+
if (!NEEDS_EXTRACTION.has(extOf(path))) continue;
|
|
130
|
+
|
|
131
|
+
let size;
|
|
132
|
+
try {
|
|
133
|
+
size = statSync(path).size;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw new CliError(`Could not read --${fileFlag} ${path}: ${error.message}`);
|
|
136
|
+
}
|
|
137
|
+
if (size === 0) throw new CliError(`--${fileFlag} ${path} is empty.`);
|
|
138
|
+
|
|
139
|
+
flags[inlineFlag] = await extractViaDoor(client, path);
|
|
140
|
+
}
|
|
141
|
+
}
|
package/package.json
CHANGED
package/lib/commands/deploy.mjs
DELETED
|
@@ -1,389 +0,0 @@
|
|
|
1
|
-
// Phantom blogs (deployments) and custom domains. `cmdDeploy`/`cmdDomain` are the
|
|
2
|
-
// original, stable command surface; `blogs`/`domains` are Mathew's phantomstory-cli
|
|
3
|
-
// names for the exact same operations. Rather than duplicate handler bodies, `blogs`
|
|
4
|
-
// and `domains` are literal references to `cmdDeploy`/`cmdDomain` (see the bottom of
|
|
5
|
-
// this file) and Mathew's verb spellings (ls/new/show/rm/diag) are additional cases
|
|
6
|
-
// in the same switch statements below — one implementation, two vocabularies.
|
|
7
|
-
|
|
8
|
-
import { CliError } from "../client.mjs";
|
|
9
|
-
import { flagStr, flagNum, requireFlag, requirePositional, printResult, compact } from "./shared.mjs";
|
|
10
|
-
|
|
11
|
-
function stripScheme(domain) {
|
|
12
|
-
return domain.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function formatDeployment(d) {
|
|
16
|
-
const lines = [
|
|
17
|
-
`${d.name} [${d.deployment_id}]`,
|
|
18
|
-
` status: ${d.status}${d.phase ? ` · ${d.phase}` : ""}`,
|
|
19
|
-
` theme: ${d.theme}`,
|
|
20
|
-
` collection: ${d.collection_id ?? "(none)"}`,
|
|
21
|
-
];
|
|
22
|
-
if (d.url) lines.push(` url: ${d.url}`);
|
|
23
|
-
if (d.domain) lines.push(` domain: ${d.domain}`);
|
|
24
|
-
if (d.last_error) lines.push(` error: ${d.last_error}`);
|
|
25
|
-
return lines.join("\n");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Poll get_deployment until the blog reaches a terminal state. Emits a line only
|
|
29
|
-
// when status/phase changes so the output reads as progress, not a firehose.
|
|
30
|
-
async function pollUntilTerminal(client, id, io, bin) {
|
|
31
|
-
const maxPolls = io.maxPolls ?? 90;
|
|
32
|
-
const interval = io.pollIntervalMs ?? 4000;
|
|
33
|
-
let last = "";
|
|
34
|
-
for (let i = 0; i < maxPolls; i++) {
|
|
35
|
-
const d = await client.callTool("get_deployment", { deployment_id: id });
|
|
36
|
-
if (d.status === "live" || d.status === "error") return d;
|
|
37
|
-
const line = `${d.status}${d.phase ? ` · ${d.phase}` : ""}`;
|
|
38
|
-
if (line !== last) {
|
|
39
|
-
io.log(` … ${line}`);
|
|
40
|
-
last = line;
|
|
41
|
-
}
|
|
42
|
-
await io.sleep(interval);
|
|
43
|
-
}
|
|
44
|
-
return {
|
|
45
|
-
status: "error",
|
|
46
|
-
last_error: `Timed out waiting for it to go live. Check: ${bin} deploy get ${id}`,
|
|
47
|
-
url: null,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// --- deployments (phantom blogs) --------------------------------------------
|
|
52
|
-
|
|
53
|
-
export async function cmdDeploy(ctx) {
|
|
54
|
-
const sub = ctx.positionals[0];
|
|
55
|
-
// Bare `deploy` (no subcommand word) is phantomstory-cli's flagship, flag-only
|
|
56
|
-
// invocation — `phantom deploy --domain x.com --buy` — not one of the create/
|
|
57
|
-
// list/get/… verbs below, so route it to the orchestrator before the switch.
|
|
58
|
-
if (sub === undefined) return deployOrchestrate(ctx);
|
|
59
|
-
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
60
|
-
switch (sub) {
|
|
61
|
-
case "create":
|
|
62
|
-
case "new":
|
|
63
|
-
return deployCreate(rest);
|
|
64
|
-
case "list":
|
|
65
|
-
case "ls":
|
|
66
|
-
return deployList(rest);
|
|
67
|
-
case "get":
|
|
68
|
-
case "status":
|
|
69
|
-
case "show":
|
|
70
|
-
return deployGet(rest);
|
|
71
|
-
case "update":
|
|
72
|
-
return deployUpdate(rest);
|
|
73
|
-
case "rebuild":
|
|
74
|
-
return deployRebuild(rest);
|
|
75
|
-
case "diagnostics":
|
|
76
|
-
case "diag":
|
|
77
|
-
return deployDiagnostics(rest);
|
|
78
|
-
case "delete":
|
|
79
|
-
case "rm":
|
|
80
|
-
return deployDelete(rest);
|
|
81
|
-
default:
|
|
82
|
-
throw new CliError(
|
|
83
|
-
`Unknown deploy subcommand: ${sub}. Try: create, list, get, update, rebuild, diagnostics, delete — or bare ` +
|
|
84
|
-
`"${ctx.bin} deploy --domain <domain>" to reserve+price+build in one step.`
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// --- one-shot orchestrator: reserve/locate → price domain → optionally buy →
|
|
90
|
-
// rebuild-if-changed. Mirrors phantomstory-cli's flagship `phantom deploy` in this
|
|
91
|
-
// CLI's own plain-text conventions (no spinners/animation, see commands/README notes
|
|
92
|
-
// on the ui/render toolkit not being ported).
|
|
93
|
-
function nameFromDomain(domain) {
|
|
94
|
-
if (!domain) return undefined;
|
|
95
|
-
const host = domain
|
|
96
|
-
.replace(/^https?:\/\//, "")
|
|
97
|
-
.replace(/^www\./, "")
|
|
98
|
-
.split("/")[0];
|
|
99
|
-
const base = host.split(".")[0];
|
|
100
|
-
if (!base) return undefined;
|
|
101
|
-
return base
|
|
102
|
-
.split(/[-_]/)
|
|
103
|
-
.filter(Boolean)
|
|
104
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
105
|
-
.join(" ");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function pickId(obj) {
|
|
109
|
-
if (obj && typeof obj === "object") {
|
|
110
|
-
for (const key of ["deployment_id", "id", "deploymentId"]) {
|
|
111
|
-
if (typeof obj[key] === "string") return obj[key];
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function liveUrl(domain, collected) {
|
|
118
|
-
if (domain) return `https://${domain}`;
|
|
119
|
-
for (const v of Object.values(collected)) {
|
|
120
|
-
if (v && typeof v === "object") {
|
|
121
|
-
const url = v.url ?? v.live_url ?? v.blog_url;
|
|
122
|
-
if (typeof url === "string") return url;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function deployOrchestrate(ctx) {
|
|
129
|
-
const { client, flags, io } = ctx;
|
|
130
|
-
const domainFlag = flagStr(flags.domain);
|
|
131
|
-
const nameFlag = flagStr(flags.name);
|
|
132
|
-
const themeFlag = flagStr(flags.theme);
|
|
133
|
-
const collectionFlag = flagStr(flags.collection);
|
|
134
|
-
const blogFlag = flagStr(flags.blog);
|
|
135
|
-
const buy = Boolean(flags.buy);
|
|
136
|
-
const forceRebuild = Boolean(flags.rebuild);
|
|
137
|
-
const domain = domainFlag !== undefined ? stripScheme(domainFlag) : undefined;
|
|
138
|
-
|
|
139
|
-
if (!domain && !nameFlag && !blogFlag) {
|
|
140
|
-
throw new CliError(
|
|
141
|
-
`Nothing to deploy — pass --domain, --name, or --blog. e.g. "${ctx.bin} deploy --domain yourverticalreview.com"`
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
if (buy && !domain) {
|
|
145
|
-
throw new CliError("--buy needs a --domain to register.");
|
|
146
|
-
}
|
|
147
|
-
if (blogFlag && (nameFlag || themeFlag || collectionFlag)) {
|
|
148
|
-
throw new CliError(
|
|
149
|
-
`--name/--theme/--collection can't be combined with --blog. Use "${ctx.bin} deploy update <id>" to rename, re-theme, or repoint an existing blog.`
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (flags["dry-run"]) {
|
|
154
|
-
const name = nameFlag || nameFromDomain(domain) || "Phantom Blog";
|
|
155
|
-
io.log(`Dry run — would deploy with:`);
|
|
156
|
-
io.log(
|
|
157
|
-
JSON.stringify(
|
|
158
|
-
compact({
|
|
159
|
-
blog: blogFlag,
|
|
160
|
-
name: blogFlag ? undefined : name,
|
|
161
|
-
theme: themeFlag,
|
|
162
|
-
collection: collectionFlag,
|
|
163
|
-
domain,
|
|
164
|
-
buy,
|
|
165
|
-
rebuild: forceRebuild,
|
|
166
|
-
}),
|
|
167
|
-
null,
|
|
168
|
-
2
|
|
169
|
-
)
|
|
170
|
-
);
|
|
171
|
-
if (domain) io.log(`\nWould be live at https://${domain}`);
|
|
172
|
-
return 0;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const collected = {};
|
|
176
|
-
let changed = false;
|
|
177
|
-
let deploymentId = blogFlag;
|
|
178
|
-
|
|
179
|
-
if (deploymentId) {
|
|
180
|
-
collected.blog = await client.callTool("get_deployment", { deployment_id: deploymentId });
|
|
181
|
-
io.log(`Found phantom blog ${deploymentId}`);
|
|
182
|
-
} else {
|
|
183
|
-
const name = nameFlag || nameFromDomain(domain) || "Phantom Blog";
|
|
184
|
-
const created = await client.callTool(
|
|
185
|
-
"create_deployment",
|
|
186
|
-
compact({ name, theme: themeFlag, collection_id: collectionFlag })
|
|
187
|
-
);
|
|
188
|
-
collected.blog = created;
|
|
189
|
-
deploymentId = pickId(created);
|
|
190
|
-
changed = true;
|
|
191
|
-
io.log(`Reserved phantom blog "${name}"${deploymentId ? ` (${deploymentId})` : ""}`);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (domain) {
|
|
195
|
-
collected.domain = await client.callTool("check_domain", { domain });
|
|
196
|
-
io.log(`Priced ${domain}`);
|
|
197
|
-
|
|
198
|
-
if (buy) {
|
|
199
|
-
if (!deploymentId) throw new CliError("Cannot register a domain without a blog id.");
|
|
200
|
-
collected.purchase = await client.callTool("buy_domain", { deployment_id: deploymentId, domain });
|
|
201
|
-
changed = true;
|
|
202
|
-
io.log(`Registered ${domain}`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (deploymentId && (changed || forceRebuild)) {
|
|
207
|
-
collected.build = await client.callTool("rebuild_deployment", { deployment_id: deploymentId });
|
|
208
|
-
io.log(`SSL + DNS provisioned · sitemap + schema generated`);
|
|
209
|
-
try {
|
|
210
|
-
collected.diagnostics = await client.callTool("get_deployment_diagnostics", {
|
|
211
|
-
deployment_id: deploymentId,
|
|
212
|
-
});
|
|
213
|
-
} catch {
|
|
214
|
-
// best-effort — diagnostics may not be ready immediately after a build kicks off
|
|
215
|
-
}
|
|
216
|
-
} else if (deploymentId) {
|
|
217
|
-
io.log(`No changes — skipped rebuild (pass --rebuild to force).`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (flags.json) {
|
|
221
|
-
io.log(JSON.stringify(collected, null, 2));
|
|
222
|
-
return 0;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const url = liveUrl(domain, collected);
|
|
226
|
-
io.log(`\nYour phantom blog is live${url ? ` at ${url}` : ""}.`);
|
|
227
|
-
return 0;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
async function deployCreate(ctx) {
|
|
231
|
-
const { client, flags, io } = ctx;
|
|
232
|
-
const name = requireFlag(flags, "name");
|
|
233
|
-
const args = { name };
|
|
234
|
-
const description = flagStr(flags.description);
|
|
235
|
-
const theme = flagStr(flags.theme);
|
|
236
|
-
const collection = flagStr(flags.collection);
|
|
237
|
-
if (description !== undefined) args.description = description;
|
|
238
|
-
if (theme !== undefined) args.theme = theme;
|
|
239
|
-
if (collection !== undefined) args.collection_id = collection;
|
|
240
|
-
|
|
241
|
-
if (flags["dry-run"]) {
|
|
242
|
-
io.log(`Dry run — would create a phantom blog with:`);
|
|
243
|
-
io.log(JSON.stringify(args, null, 2));
|
|
244
|
-
return 0;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const created = await client.callTool("create_deployment", args);
|
|
248
|
-
io.log(`Created deployment ${created.deployment_id} (${created.status})`);
|
|
249
|
-
|
|
250
|
-
if (flags["no-wait"]) {
|
|
251
|
-
io.log(`Provisioning in the background. Poll with: ${ctx.bin} deploy get ${created.deployment_id}`);
|
|
252
|
-
return 0;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const final = await pollUntilTerminal(client, created.deployment_id, io, ctx.bin);
|
|
256
|
-
if (final.status === "live") {
|
|
257
|
-
io.log(`\n✓ Live at ${final.url}`);
|
|
258
|
-
return 0;
|
|
259
|
-
}
|
|
260
|
-
io.error(`\n✗ Deployment ${final.status}: ${final.last_error ?? "unknown error"}`);
|
|
261
|
-
return 1;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async function deployList(ctx) {
|
|
265
|
-
const { client, flags, io } = ctx;
|
|
266
|
-
const limit = flagNum(flags.limit);
|
|
267
|
-
const result = await client.callTool("list_deployments", limit !== undefined ? { limit } : {});
|
|
268
|
-
const items = result.items ?? [];
|
|
269
|
-
if (flags.json) {
|
|
270
|
-
io.log(JSON.stringify(result, null, 2));
|
|
271
|
-
return 0;
|
|
272
|
-
}
|
|
273
|
-
if (items.length === 0) {
|
|
274
|
-
io.log(`No deployments yet. Create one with: ${ctx.bin} deploy create --name <name>`);
|
|
275
|
-
return 0;
|
|
276
|
-
}
|
|
277
|
-
for (const d of items) {
|
|
278
|
-
io.log(
|
|
279
|
-
`${d.status.padEnd(13)} ${d.deployment_id} collection:${d.collection_id ?? "(none)"} ${d.name}${d.url ? ` ${d.url}` : ""}`
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
return 0;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
async function deployGet(ctx) {
|
|
286
|
-
const { client, positionals, flags, io } = ctx;
|
|
287
|
-
const id = requirePositional(positionals, 0, "deployment-id");
|
|
288
|
-
const d = await client.callTool("get_deployment", { deployment_id: id });
|
|
289
|
-
printResult(io, flags, d, formatDeployment);
|
|
290
|
-
return 0;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async function deployUpdate(ctx) {
|
|
294
|
-
const { client, positionals, flags, io } = ctx;
|
|
295
|
-
const id = requirePositional(positionals, 0, "deployment-id");
|
|
296
|
-
const patch = compact({
|
|
297
|
-
deployment_id: id,
|
|
298
|
-
name: flagStr(flags.name),
|
|
299
|
-
description: flagStr(flags.description),
|
|
300
|
-
theme: flagStr(flags.theme),
|
|
301
|
-
collection_id: flagStr(flags.collection),
|
|
302
|
-
});
|
|
303
|
-
if (Object.keys(patch).length <= 1) {
|
|
304
|
-
throw new CliError("Nothing to update — pass at least one of --name/--description/--theme/--collection.");
|
|
305
|
-
}
|
|
306
|
-
const d = await client.callTool("update_deployment", patch);
|
|
307
|
-
io.log(`Updated deployment ${id}.`);
|
|
308
|
-
printResult(io, flags, d, formatDeployment);
|
|
309
|
-
return 0;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
async function deployRebuild(ctx) {
|
|
313
|
-
const { client, positionals, io } = ctx;
|
|
314
|
-
const id = requirePositional(positionals, 0, "deployment-id");
|
|
315
|
-
await client.callTool("rebuild_deployment", { deployment_id: id });
|
|
316
|
-
io.log(`Rebuild started for ${id}. New published content will appear once the build finishes.`);
|
|
317
|
-
return 0;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
async function deployDiagnostics(ctx) {
|
|
321
|
-
const { client, positionals, io } = ctx;
|
|
322
|
-
const id = requirePositional(positionals, 0, "deployment-id");
|
|
323
|
-
const diag = await client.callTool("get_deployment_diagnostics", { deployment_id: id });
|
|
324
|
-
io.log(JSON.stringify(diag, null, 2));
|
|
325
|
-
return 0;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
async function deployDelete(ctx) {
|
|
329
|
-
const { client, positionals, flags, io } = ctx;
|
|
330
|
-
const id = requirePositional(positionals, 0, "deployment-id");
|
|
331
|
-
if (!flags.yes) {
|
|
332
|
-
io.error(`This tears down the blog's site and revokes its content key (the collection is kept).`);
|
|
333
|
-
io.error(`Re-run with --yes to confirm: ${ctx.bin} deploy delete ${id} --yes`);
|
|
334
|
-
return 1;
|
|
335
|
-
}
|
|
336
|
-
await client.callTool("delete_deployment", { deployment_id: id });
|
|
337
|
-
io.log(`Deleted deployment ${id}.`);
|
|
338
|
-
return 0;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// --- custom domains ---------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
export async function cmdDomain(ctx) {
|
|
344
|
-
const sub = ctx.positionals[0];
|
|
345
|
-
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
346
|
-
switch (sub) {
|
|
347
|
-
case "check":
|
|
348
|
-
return domainCheck(rest);
|
|
349
|
-
case "buy":
|
|
350
|
-
return domainBuy(rest);
|
|
351
|
-
default:
|
|
352
|
-
throw new CliError(`Unknown domain subcommand: ${sub ?? "(none)"}. Try: check, buy`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async function domainCheck(ctx) {
|
|
357
|
-
const { client, positionals, flags, io } = ctx;
|
|
358
|
-
const domain = stripScheme(requirePositional(positionals, 0, "domain"));
|
|
359
|
-
const quote = await client.callTool("check_domain", { domain });
|
|
360
|
-
if (flags.json) {
|
|
361
|
-
io.log(JSON.stringify(quote, null, 2));
|
|
362
|
-
return 0;
|
|
363
|
-
}
|
|
364
|
-
io.log(`${domain}`);
|
|
365
|
-
io.log(` available: ${quote.available}`);
|
|
366
|
-
if (quote.price !== undefined) io.log(` price: $${quote.price}`);
|
|
367
|
-
io.log(` purchasable: ${quote.purchasable}`);
|
|
368
|
-
return 0;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// No confirm gate: the app's BuyDomainDialog charges on a single "Get this domain"
|
|
372
|
-
// click, and the CLI mirrors the app's user-facing flow. The spend is bounded on the
|
|
373
|
-
// server, not by a client prompt — `buy_domain` is server-priced and hard-capped
|
|
374
|
-
// (≤2 domains/org, <$30, blog must be live with no domain yet).
|
|
375
|
-
async function domainBuy(ctx) {
|
|
376
|
-
const { client, positionals, flags, io } = ctx;
|
|
377
|
-
const id = requirePositional(positionals, 0, "deployment-id");
|
|
378
|
-
const domain = stripScheme(requirePositional(positionals, 1, "domain"));
|
|
379
|
-
const result = await client.callTool("buy_domain", { deployment_id: id, domain });
|
|
380
|
-
io.log(`Purchase started for ${domain}. Attach + DNS run in the background.`);
|
|
381
|
-
io.log(`Poll with: ${ctx.bin} deploy get ${id}`);
|
|
382
|
-
if (flags.json) io.log(JSON.stringify(result, null, 2));
|
|
383
|
-
return 0;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// `blogs`/`domains` are phantomstory-cli's names for these exact same commands — a
|
|
387
|
-
// literal reference, not a wrapper, so they can never drift from deploy/domain.
|
|
388
|
-
export const cmdBlogs = cmdDeploy;
|
|
389
|
-
export const cmdDomains = cmdDomain;
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// `phantom-job` — the topic-to-published-article orchestrator: kick off a job with
|
|
2
|
-
// a topic + collection, then check its stage (topic_set -> kernel_running ->
|
|
3
|
-
// publishing -> rebuilding -> done, or failed). The underlying kernel run alone can
|
|
4
|
-
// take 15-20 minutes, so `create` returns as soon as the job is queued rather than
|
|
5
|
-
// blocking — same shape as `flows run` / `flows status`, not `deploy create`'s
|
|
6
|
-
// wait-until-live loop.
|
|
7
|
-
|
|
8
|
-
import { CliError } from "../client.mjs";
|
|
9
|
-
import { flagStr, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
|
|
10
|
-
|
|
11
|
-
export async function cmdPhantomJob(ctx) {
|
|
12
|
-
const sub = ctx.positionals[0];
|
|
13
|
-
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
14
|
-
switch (sub) {
|
|
15
|
-
case "create":
|
|
16
|
-
return phantomJobCreate(rest);
|
|
17
|
-
case "status":
|
|
18
|
-
return phantomJobStatus(rest);
|
|
19
|
-
default:
|
|
20
|
-
throw new CliError(`Unknown phantom-job subcommand: ${sub ?? "(none)"}. Try: create, status`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function phantomJobCreate(ctx) {
|
|
25
|
-
const { client, flags, io } = ctx;
|
|
26
|
-
const topic = requireFlag(flags, "topic");
|
|
27
|
-
const collection = requireFlag(flags, "collection");
|
|
28
|
-
const args = compact({ topic, collection_id: collection, kernel_id: flagStr(flags.kernel) });
|
|
29
|
-
const result = await client.callTool("create_phantom_from_topic", args);
|
|
30
|
-
ok(ctx, `Phantom job started. Check progress with: phantom-job status ${result?.job_id ?? "<job-id>"}`);
|
|
31
|
-
printResult(io, flags, result);
|
|
32
|
-
return 0;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function phantomJobStatus(ctx) {
|
|
36
|
-
const { client, positionals, flags, io } = ctx;
|
|
37
|
-
const jobId = requirePositional(positionals, 0, "job-id");
|
|
38
|
-
const result = await client.callTool("get_phantom_job_status", { job_id: jobId });
|
|
39
|
-
printResult(io, flags, result);
|
|
40
|
-
return 0;
|
|
41
|
-
}
|