@lotics/cli 0.56.0 → 0.60.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 +36 -0
- package/dist/app_commands.d.ts +125 -3
- package/dist/app_commands.js +613 -6
- package/dist/app_commands.test.js +501 -2
- package/dist/args.d.ts +4 -0
- package/dist/args.js +9 -0
- package/dist/args.test.js +12 -0
- package/dist/child_env.d.ts +13 -0
- package/dist/child_env.js +24 -0
- package/dist/cli.js +132 -30
- package/dist/client.d.ts +66 -0
- package/dist/client.js +144 -1
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +47 -0
- package/dist/dev/server.js +2 -1
- package/dist/generate_app_fields.d.ts +54 -0
- package/dist/generate_app_fields.js +148 -0
- package/dist/generate_app_fields.test.d.ts +1 -0
- package/dist/generate_app_fields.test.js +108 -0
- package/dist/inputs.d.ts +38 -0
- package/dist/inputs.js +50 -0
- package/dist/inputs.test.d.ts +1 -0
- package/dist/inputs.test.js +89 -0
- package/dist/src/cli.js +2483 -1414
- package/dist/starter_template.js +89 -0
- package/dist/starter_template.test.js +11 -0
- package/package.json +2 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegen: emit a RUNTIME `.lotics/app_fields.ts` from a workspace schema.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the `.d.ts` companions (which only augment types), this is a real
|
|
5
|
+
* `.ts` module: apps import the string VALUES at runtime to address fields and
|
|
6
|
+
* select options by a stable, human-readable alias instead of pasting opaque
|
|
7
|
+
* `fld_…` / `opt_…` ids into their source:
|
|
8
|
+
*
|
|
9
|
+
* import { F, OPT } from "../.lotics/app_fields";
|
|
10
|
+
* record.data[F.SHIPMENTS.status] // "fld_…"
|
|
11
|
+
* if (status === OPT.SHIPMENTS.status.cleared) // "opt_…"
|
|
12
|
+
*
|
|
13
|
+
* Aliases are derived from display names (NFD-stripped, non-alnum → `_`, deduped
|
|
14
|
+
* in stable order), so a rename on the platform re-runs codegen and the app's
|
|
15
|
+
* call sites move with it. Pure function — same schema → same bytes. Idempotent.
|
|
16
|
+
*/
|
|
17
|
+
import { isValidIdentifier } from "./generate_app_workflows_dts.js";
|
|
18
|
+
const HEADER = `// Auto-generated by 'lotics app codegen' (and app pull/dev/deploy).
|
|
19
|
+
// DO NOT EDIT — regenerated from the workspace schema.
|
|
20
|
+
//
|
|
21
|
+
// Runtime field + option ids addressed by stable display-name aliases:
|
|
22
|
+
// record.data[F.<TABLE>.<field>] → "fld_…"
|
|
23
|
+
// value === OPT.<TABLE>.<field>.<option> → "opt_…"
|
|
24
|
+
// A rename on the platform re-runs codegen and moves these in lockstep.
|
|
25
|
+
`;
|
|
26
|
+
/**
|
|
27
|
+
* Slugify a display name to a valid TS identifier. NFD-normalize then strip
|
|
28
|
+
* diacritics so "Lô hàng" and "Lo hang" don't collide on the accent, lowercase,
|
|
29
|
+
* non-alnum → `_`, collapse runs, trim edge `_`. A leading digit (identifiers
|
|
30
|
+
* can't start with one) and the empty result both get a `_` prefix/placeholder.
|
|
31
|
+
* `upper` uppercases the result (TABLE aliases read as constants).
|
|
32
|
+
*/
|
|
33
|
+
export function slugifyAlias(name, upper) {
|
|
34
|
+
const stripped = name
|
|
35
|
+
.normalize("NFD")
|
|
36
|
+
.replace(/[̀-ͯ]/g, "") // combining diacritical marks
|
|
37
|
+
.replace(/đ/g, "d")
|
|
38
|
+
.replace(/Đ/g, "D"); // not a combining mark — map explicitly
|
|
39
|
+
const cased = upper ? stripped.toUpperCase() : stripped.toLowerCase();
|
|
40
|
+
const slug = cased
|
|
41
|
+
.replace(/[^a-zA-Z0-9]+/g, "_")
|
|
42
|
+
.replace(/^_+|_+$/g, "");
|
|
43
|
+
if (slug === "")
|
|
44
|
+
return "_";
|
|
45
|
+
return /^[0-9]/.test(slug) ? `_${slug}` : slug;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Assign each input a unique alias, preserving input order. The first claim on
|
|
49
|
+
* a slug keeps it; later collisions get `_2`, `_3`, … so the mapping is stable
|
|
50
|
+
* across regenerations (order is the schema's field/option order). Returns the
|
|
51
|
+
* aliases positionally aligned with `names`.
|
|
52
|
+
*/
|
|
53
|
+
function dedupeAliases(names, upper) {
|
|
54
|
+
const used = new Map();
|
|
55
|
+
return names.map((name) => {
|
|
56
|
+
const base = slugifyAlias(name, upper);
|
|
57
|
+
const seen = used.get(base);
|
|
58
|
+
if (seen === undefined) {
|
|
59
|
+
used.set(base, 1);
|
|
60
|
+
return base;
|
|
61
|
+
}
|
|
62
|
+
let n = seen + 1;
|
|
63
|
+
while (used.has(`${base}_${n}`))
|
|
64
|
+
n++;
|
|
65
|
+
used.set(base, n);
|
|
66
|
+
used.set(`${base}_${n}`, 1);
|
|
67
|
+
return `${base}_${n}`;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** A property key for an object literal — bare when a valid identifier, else quoted. */
|
|
71
|
+
function propKey(alias) {
|
|
72
|
+
return isValidIdentifier(alias) ? alias : JSON.stringify(alias);
|
|
73
|
+
}
|
|
74
|
+
/** Resolve table + field aliases once, so the `F`/`OPT` maps and the union types agree. */
|
|
75
|
+
function aliasTables(tables) {
|
|
76
|
+
const tableAliases = dedupeAliases(tables.map((t) => t.name), true);
|
|
77
|
+
return tables.map((table, i) => {
|
|
78
|
+
const fieldAliases = dedupeAliases(table.fields.map((f) => f.name), false);
|
|
79
|
+
return {
|
|
80
|
+
alias: tableAliases[i],
|
|
81
|
+
table,
|
|
82
|
+
fields: table.fields.map((field, j) => ({ alias: fieldAliases[j], field })),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Emit the `F` map: `{ TABLE: { field: "fld_…" } }` plus its literal-union type. */
|
|
87
|
+
function emitFieldMap(aliased) {
|
|
88
|
+
const tableBlocks = aliased.map(({ alias, fields }) => {
|
|
89
|
+
const fieldLines = fields.map(({ alias: fieldAlias, field }) => ` ${propKey(fieldAlias)}: ${JSON.stringify(field.id)},`);
|
|
90
|
+
return ` ${propKey(alias)}: {\n${fieldLines.join("\n")}\n },`;
|
|
91
|
+
});
|
|
92
|
+
return `export const F = {\n${tableBlocks.join("\n")}\n} as const;`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Emit the `OPT` map: `{ TABLE: { selectField: { option: "opt_…" } } }`. Only
|
|
96
|
+
* fields that carry options appear. Tables and fields with no options are
|
|
97
|
+
* omitted entirely (so `OPT.SHIPMENTS` may be absent — that's by design).
|
|
98
|
+
*/
|
|
99
|
+
function emitOptionMap(aliased) {
|
|
100
|
+
const tableBlocks = [];
|
|
101
|
+
for (const { alias, fields } of aliased) {
|
|
102
|
+
const fieldBlocks = [];
|
|
103
|
+
for (const { alias: fieldAlias, field } of fields) {
|
|
104
|
+
const options = field.options ?? [];
|
|
105
|
+
if (options.length === 0)
|
|
106
|
+
continue;
|
|
107
|
+
const optionAliases = dedupeAliases(options.map((o) => o.label), false);
|
|
108
|
+
const optionLines = options.map((option, i) => ` ${propKey(optionAliases[i])}: ${JSON.stringify(option.id)},`);
|
|
109
|
+
fieldBlocks.push(` ${propKey(fieldAlias)}: {\n${optionLines.join("\n")}\n },`);
|
|
110
|
+
}
|
|
111
|
+
if (fieldBlocks.length === 0)
|
|
112
|
+
continue;
|
|
113
|
+
tableBlocks.push(` ${propKey(alias)}: {\n${fieldBlocks.join("\n")}\n },`);
|
|
114
|
+
}
|
|
115
|
+
if (tableBlocks.length === 0)
|
|
116
|
+
return `export const OPT = {} as const;`;
|
|
117
|
+
return `export const OPT = {\n${tableBlocks.join("\n")}\n} as const;`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Generate the full `.lotics/app_fields.ts` source. `tables` is the resolved
|
|
121
|
+
* workspace schema (the subset the app touches). An empty list yields valid,
|
|
122
|
+
* empty `F`/`OPT` maps so the file always compiles and imports resolve.
|
|
123
|
+
*/
|
|
124
|
+
export function generateAppFields(tables) {
|
|
125
|
+
if (tables.length === 0) {
|
|
126
|
+
return `${HEADER}
|
|
127
|
+
export const F = {} as const;
|
|
128
|
+
|
|
129
|
+
export const OPT = {} as const;
|
|
130
|
+
|
|
131
|
+
/** Field-id alias map (empty — no tables in scope). */
|
|
132
|
+
export type AppFields = typeof F;
|
|
133
|
+
/** Select-option alias map (empty — no tables in scope). */
|
|
134
|
+
export type AppOptions = typeof OPT;
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
const aliased = aliasTables(tables);
|
|
138
|
+
return `${HEADER}
|
|
139
|
+
${emitFieldMap(aliased)}
|
|
140
|
+
|
|
141
|
+
${emitOptionMap(aliased)}
|
|
142
|
+
|
|
143
|
+
/** Field-id alias map: \`F[<TABLE>][<field>]\` is the \`fld_…\` id (literal-typed). */
|
|
144
|
+
export type AppFields = typeof F;
|
|
145
|
+
/** Select-option alias map: \`OPT[<TABLE>][<field>][<option>]\` is the \`opt_…\` id. */
|
|
146
|
+
export type AppOptions = typeof OPT;
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateAppFields, slugifyAlias } from "./generate_app_fields.js";
|
|
3
|
+
describe("slugifyAlias", () => {
|
|
4
|
+
it("strips Vietnamese diacritics so accented + unaccented names don't collide on the accent", () => {
|
|
5
|
+
expect(slugifyAlias("Lô hàng", true)).toBe("LO_HANG");
|
|
6
|
+
expect(slugifyAlias("Đơn vị", true)).toBe("DON_VI");
|
|
7
|
+
expect(slugifyAlias("trạng thái", false)).toBe("trang_thai");
|
|
8
|
+
});
|
|
9
|
+
it("collapses non-alnum runs to a single underscore and trims edges", () => {
|
|
10
|
+
expect(slugifyAlias(" Báo giá / CTA ", true)).toBe("BAO_GIA_CTA");
|
|
11
|
+
expect(slugifyAlias("a---b", false)).toBe("a_b");
|
|
12
|
+
});
|
|
13
|
+
it("prefixes a leading digit (identifiers can't start with one)", () => {
|
|
14
|
+
expect(slugifyAlias("40HC cont", false)).toBe("_40hc_cont");
|
|
15
|
+
});
|
|
16
|
+
it("falls back to '_' for an all-symbol or empty name", () => {
|
|
17
|
+
expect(slugifyAlias("***", false)).toBe("_");
|
|
18
|
+
expect(slugifyAlias("", true)).toBe("_");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe("generateAppFields", () => {
|
|
22
|
+
it("emits empty F/OPT maps that still compile when no tables are in scope", () => {
|
|
23
|
+
const out = generateAppFields([]);
|
|
24
|
+
expect(out).toContain("export const F = {} as const;");
|
|
25
|
+
expect(out).toContain("export const OPT = {} as const;");
|
|
26
|
+
});
|
|
27
|
+
it("emits F entries keyed by slugified table + field names", () => {
|
|
28
|
+
const tables = [
|
|
29
|
+
{
|
|
30
|
+
id: "tbl_1",
|
|
31
|
+
name: "Lô hàng",
|
|
32
|
+
fields: [
|
|
33
|
+
{ id: "fld_status", name: "Trạng thái" },
|
|
34
|
+
{ id: "fld_eta", name: "ETA" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
const out = generateAppFields(tables);
|
|
39
|
+
expect(out).toContain("LO_HANG: {");
|
|
40
|
+
expect(out).toContain('trang_thai: "fld_status"');
|
|
41
|
+
expect(out).toContain('eta: "fld_eta"');
|
|
42
|
+
});
|
|
43
|
+
it("emits OPT only for fields that carry options, keyed by option label", () => {
|
|
44
|
+
const tables = [
|
|
45
|
+
{
|
|
46
|
+
id: "tbl_1",
|
|
47
|
+
name: "Shipments",
|
|
48
|
+
fields: [
|
|
49
|
+
{
|
|
50
|
+
id: "fld_status",
|
|
51
|
+
name: "Status",
|
|
52
|
+
options: [
|
|
53
|
+
{ id: "opt_open", label: "Open" },
|
|
54
|
+
{ id: "opt_cleared", label: "Cleared" },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{ id: "fld_eta", name: "ETA" },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
const out = generateAppFields(tables);
|
|
62
|
+
expect(out).toContain("SHIPMENTS: {");
|
|
63
|
+
expect(out).toContain('open: "opt_open"');
|
|
64
|
+
expect(out).toContain('cleared: "opt_cleared"');
|
|
65
|
+
// No options on ETA → it must not appear in the OPT block.
|
|
66
|
+
const optBlock = out.slice(out.indexOf("export const OPT"));
|
|
67
|
+
expect(optBlock).not.toContain("eta:");
|
|
68
|
+
});
|
|
69
|
+
it("omits a table from OPT entirely when none of its fields have options", () => {
|
|
70
|
+
const tables = [
|
|
71
|
+
{ id: "tbl_1", name: "Plain", fields: [{ id: "fld_a", name: "A" }] },
|
|
72
|
+
];
|
|
73
|
+
const out = generateAppFields(tables);
|
|
74
|
+
expect(out).toContain("export const OPT = {} as const;");
|
|
75
|
+
});
|
|
76
|
+
it("dedupes collided aliases with _2/_3 in stable field order", () => {
|
|
77
|
+
const tables = [
|
|
78
|
+
{
|
|
79
|
+
id: "tbl_1",
|
|
80
|
+
name: "T",
|
|
81
|
+
fields: [
|
|
82
|
+
{ id: "fld_a", name: "Ngày" },
|
|
83
|
+
{ id: "fld_b", name: "ngày" }, // slugifies to the same `ngay`
|
|
84
|
+
{ id: "fld_c", name: "Ngày!" }, // also `ngay`
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
const out = generateAppFields(tables);
|
|
89
|
+
expect(out).toContain('ngay: "fld_a"');
|
|
90
|
+
expect(out).toContain('ngay_2: "fld_b"');
|
|
91
|
+
expect(out).toContain('ngay_3: "fld_c"');
|
|
92
|
+
});
|
|
93
|
+
it("quotes a non-identifier alias only when slugify could not produce one", () => {
|
|
94
|
+
// An all-symbol name slugifies to "_", a valid identifier — so it's bare.
|
|
95
|
+
const tables = [
|
|
96
|
+
{ id: "tbl_1", name: "***", fields: [{ id: "fld_a", name: "@@@" }] },
|
|
97
|
+
];
|
|
98
|
+
const out = generateAppFields(tables);
|
|
99
|
+
expect(out).toContain("_: {");
|
|
100
|
+
expect(out).toContain('_: "fld_a"');
|
|
101
|
+
});
|
|
102
|
+
it("is deterministic — same schema → byte-identical output", () => {
|
|
103
|
+
const tables = [
|
|
104
|
+
{ id: "tbl_1", name: "A", fields: [{ id: "fld_a", name: "X" }] },
|
|
105
|
+
];
|
|
106
|
+
expect(generateAppFields(tables)).toBe(generateAppFields(tables));
|
|
107
|
+
});
|
|
108
|
+
});
|
package/dist/inputs.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON-args ingestion for `lotics run` and `lotics app workflow`.
|
|
3
|
+
*
|
|
4
|
+
* A tool/workflow payload can arrive three ways, all of which bypass the OS
|
|
5
|
+
* `ARG_MAX` limit so bulk inputs (a knowledge-doc `content`, a batch update)
|
|
6
|
+
* don't overflow the inline arg:
|
|
7
|
+
* - inline: `… '<json>'`
|
|
8
|
+
* - `@file`: `… @args.json` (a leading `@` is unambiguous — JSON starts `{`)
|
|
9
|
+
* - stdin: `… < args.json` or `cat args.json | …`
|
|
10
|
+
*
|
|
11
|
+
* Kept apart from `cli.ts` (which runs `main()` on import) so the file/JSON
|
|
12
|
+
* branches are unit-testable; `readStdin` is injected so the tests don't touch
|
|
13
|
+
* the real process stdin.
|
|
14
|
+
*/
|
|
15
|
+
export type IngestResult = {
|
|
16
|
+
kind: "ok";
|
|
17
|
+
args: Record<string, unknown>;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "error";
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
export interface IngestOptions {
|
|
23
|
+
/** The positional arg as parsed (`'<json>'`, `@file`, or undefined). */
|
|
24
|
+
rawArg: string | undefined;
|
|
25
|
+
/** Whether stdin is a TTY — when false and no rawArg, read piped stdin. */
|
|
26
|
+
stdinIsTTY: boolean;
|
|
27
|
+
/** Reads a UTF-8 file. Injected for testability. */
|
|
28
|
+
readFile: (path: string) => string;
|
|
29
|
+
/** Reads all of stdin. Injected for testability. */
|
|
30
|
+
readStdin: () => Promise<string>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the raw payload string, then JSON.parse it. An absent payload (no
|
|
34
|
+
* arg, no piped stdin) is a valid empty `{}` — some tools/workflows take no
|
|
35
|
+
* inputs. A `@file` read failure or invalid JSON is a typed error the caller
|
|
36
|
+
* surfaces; this function never throws or exits.
|
|
37
|
+
*/
|
|
38
|
+
export declare function ingestJsonArgs(opts: IngestOptions): Promise<IngestResult>;
|
package/dist/inputs.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON-args ingestion for `lotics run` and `lotics app workflow`.
|
|
3
|
+
*
|
|
4
|
+
* A tool/workflow payload can arrive three ways, all of which bypass the OS
|
|
5
|
+
* `ARG_MAX` limit so bulk inputs (a knowledge-doc `content`, a batch update)
|
|
6
|
+
* don't overflow the inline arg:
|
|
7
|
+
* - inline: `… '<json>'`
|
|
8
|
+
* - `@file`: `… @args.json` (a leading `@` is unambiguous — JSON starts `{`)
|
|
9
|
+
* - stdin: `… < args.json` or `cat args.json | …`
|
|
10
|
+
*
|
|
11
|
+
* Kept apart from `cli.ts` (which runs `main()` on import) so the file/JSON
|
|
12
|
+
* branches are unit-testable; `readStdin` is injected so the tests don't touch
|
|
13
|
+
* the real process stdin.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the raw payload string, then JSON.parse it. An absent payload (no
|
|
17
|
+
* arg, no piped stdin) is a valid empty `{}` — some tools/workflows take no
|
|
18
|
+
* inputs. A `@file` read failure or invalid JSON is a typed error the caller
|
|
19
|
+
* surfaces; this function never throws or exits.
|
|
20
|
+
*/
|
|
21
|
+
export async function ingestJsonArgs(opts) {
|
|
22
|
+
let raw = opts.rawArg;
|
|
23
|
+
if (raw && raw.startsWith("@")) {
|
|
24
|
+
const argsPath = raw.slice(1);
|
|
25
|
+
try {
|
|
26
|
+
raw = opts.readFile(argsPath);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return {
|
|
30
|
+
kind: "error",
|
|
31
|
+
message: `Cannot read args file "${argsPath}": ${err instanceof Error ? err.message : String(err)}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (!raw && !opts.stdinIsTTY) {
|
|
36
|
+
raw = await opts.readStdin();
|
|
37
|
+
}
|
|
38
|
+
if (!raw)
|
|
39
|
+
return { kind: "ok", args: {} };
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
43
|
+
return { kind: "error", message: `JSON args must be an object, got: ${raw}` };
|
|
44
|
+
}
|
|
45
|
+
return { kind: "ok", args: parsed };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { kind: "error", message: `Invalid JSON: ${raw}` };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ingestJsonArgs } from "./inputs.js";
|
|
3
|
+
const neverFile = () => {
|
|
4
|
+
throw new Error("readFile should not be called");
|
|
5
|
+
};
|
|
6
|
+
const neverStdin = async () => {
|
|
7
|
+
throw new Error("readStdin should not be called");
|
|
8
|
+
};
|
|
9
|
+
describe("ingestJsonArgs", () => {
|
|
10
|
+
it("parses an inline JSON object arg without touching file/stdin", async () => {
|
|
11
|
+
const r = await ingestJsonArgs({
|
|
12
|
+
rawArg: '{"table_id":"tbl_1"}',
|
|
13
|
+
stdinIsTTY: true,
|
|
14
|
+
readFile: neverFile,
|
|
15
|
+
readStdin: neverStdin,
|
|
16
|
+
});
|
|
17
|
+
expect(r).toEqual({ kind: "ok", args: { table_id: "tbl_1" } });
|
|
18
|
+
});
|
|
19
|
+
it("reads @file args (bypassing ARG_MAX)", async () => {
|
|
20
|
+
const r = await ingestJsonArgs({
|
|
21
|
+
rawArg: "@args.json",
|
|
22
|
+
stdinIsTTY: true,
|
|
23
|
+
readFile: (p) => {
|
|
24
|
+
expect(p).toBe("args.json");
|
|
25
|
+
return '{"big":"payload"}';
|
|
26
|
+
},
|
|
27
|
+
readStdin: neverStdin,
|
|
28
|
+
});
|
|
29
|
+
expect(r).toEqual({ kind: "ok", args: { big: "payload" } });
|
|
30
|
+
});
|
|
31
|
+
it("surfaces a typed error when the @file can't be read", async () => {
|
|
32
|
+
const r = await ingestJsonArgs({
|
|
33
|
+
rawArg: "@missing.json",
|
|
34
|
+
stdinIsTTY: true,
|
|
35
|
+
readFile: () => {
|
|
36
|
+
throw new Error("ENOENT");
|
|
37
|
+
},
|
|
38
|
+
readStdin: neverStdin,
|
|
39
|
+
});
|
|
40
|
+
expect(r.kind).toBe("error");
|
|
41
|
+
if (r.kind === "error")
|
|
42
|
+
expect(r.message).toContain("missing.json");
|
|
43
|
+
});
|
|
44
|
+
it("reads piped stdin when there's no arg and stdin is not a TTY", async () => {
|
|
45
|
+
const r = await ingestJsonArgs({
|
|
46
|
+
rawArg: undefined,
|
|
47
|
+
stdinIsTTY: false,
|
|
48
|
+
readFile: neverFile,
|
|
49
|
+
readStdin: async () => '{"from":"stdin"}',
|
|
50
|
+
});
|
|
51
|
+
expect(r).toEqual({ kind: "ok", args: { from: "stdin" } });
|
|
52
|
+
});
|
|
53
|
+
it("treats an absent payload (no arg, TTY stdin) as empty {}", async () => {
|
|
54
|
+
const r = await ingestJsonArgs({
|
|
55
|
+
rawArg: undefined,
|
|
56
|
+
stdinIsTTY: true,
|
|
57
|
+
readFile: neverFile,
|
|
58
|
+
readStdin: neverStdin,
|
|
59
|
+
});
|
|
60
|
+
expect(r).toEqual({ kind: "ok", args: {} });
|
|
61
|
+
});
|
|
62
|
+
it("errors on invalid JSON", async () => {
|
|
63
|
+
const r = await ingestJsonArgs({
|
|
64
|
+
rawArg: "{not json",
|
|
65
|
+
stdinIsTTY: true,
|
|
66
|
+
readFile: neverFile,
|
|
67
|
+
readStdin: neverStdin,
|
|
68
|
+
});
|
|
69
|
+
expect(r.kind).toBe("error");
|
|
70
|
+
if (r.kind === "error")
|
|
71
|
+
expect(r.message).toContain("Invalid JSON");
|
|
72
|
+
});
|
|
73
|
+
it("rejects a non-object top-level JSON (array / scalar)", async () => {
|
|
74
|
+
const arr = await ingestJsonArgs({
|
|
75
|
+
rawArg: "[1,2,3]",
|
|
76
|
+
stdinIsTTY: true,
|
|
77
|
+
readFile: neverFile,
|
|
78
|
+
readStdin: neverStdin,
|
|
79
|
+
});
|
|
80
|
+
expect(arr.kind).toBe("error");
|
|
81
|
+
const scalar = await ingestJsonArgs({
|
|
82
|
+
rawArg: "42",
|
|
83
|
+
stdinIsTTY: true,
|
|
84
|
+
readFile: neverFile,
|
|
85
|
+
readStdin: neverStdin,
|
|
86
|
+
});
|
|
87
|
+
expect(scalar.kind).toBe("error");
|
|
88
|
+
});
|
|
89
|
+
});
|