@letterstory/cli 0.28.0 → 0.29.0

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.
@@ -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
- return collectionsDelete(rest);
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(
@@ -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, `onboard_phantom`:
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: `onboard_phantom{accept:true}` starts a
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
- // `get_phantom_onboard_status` until the job is done (provision → configure → seed).
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
- onboard_phantom: {
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
- get_phantom_onboard_status: {
156
- job_id: { internal: "the job id onboard_phantom just returned" },
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.onboard_phantom.overrides.object, flags, [], { plan });
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
- "onboard_phantom",
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
- "onboard_phantom",
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("onboard_phantom did not return a job id.");
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("get_phantom_onboard_status", { job_id: jobId });
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
+ }
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, deploy/domain, posts,
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/deploy.mjs";
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
- export * from "./commands/phantom-job.mjs";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",