@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
package/dist/app_commands.js
CHANGED
|
@@ -17,9 +17,12 @@ import { spawn } from "node:child_process";
|
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { buildStarterTemplate } from "./starter_template.js";
|
|
19
19
|
import { startDevServer, openBrowser } from "./dev/server.js";
|
|
20
|
+
import { ipv4ChildEnv } from "./child_env.js";
|
|
20
21
|
import { generateAppWorkflowsDts } from "./generate_app_workflows_dts.js";
|
|
21
22
|
import { generateAppAgentsDts } from "./generate_app_agents_dts.js";
|
|
22
23
|
import { generateAppQueriesDts } from "./generate_app_queries_dts.js";
|
|
24
|
+
import { collectQueryTableIds } from "@lotics/shared/app_query_ast";
|
|
25
|
+
import { generateAppFields } from "./generate_app_fields.js";
|
|
23
26
|
/**
|
|
24
27
|
* Resolve the latest published version of a package from the npm registry.
|
|
25
28
|
* Returns null on any failure (network error, 404, malformed payload) so
|
|
@@ -46,6 +49,190 @@ async function fetchLatestNpmVersion(packageName) {
|
|
|
46
49
|
return null;
|
|
47
50
|
}
|
|
48
51
|
}
|
|
52
|
+
/** The directory, relative to the project root, that holds editable workflow bodies. */
|
|
53
|
+
const WORKFLOWS_DIR = path.join("src", "workflows");
|
|
54
|
+
/** The dot-dir that holds the per-alias ambient globals `.d.ts` (server-generated). */
|
|
55
|
+
const WORKFLOW_GLOBALS_DIR = path.join(".lotics", "workflows");
|
|
56
|
+
/**
|
|
57
|
+
* The `async function __workflow(...)` wrapper a workflow body sits inside —
|
|
58
|
+
* the SAME envelope the server compiles the body within at `set_app_workflow`
|
|
59
|
+
* verify time (GAP-59). Carried so a body that uses top-level `await` and ends
|
|
60
|
+
* with `return({...})` typechecks locally exactly as the server checks it. The
|
|
61
|
+
* server returns the canonical strings (`getAppWorkflowDts`); these are the
|
|
62
|
+
* offline fallback when the dts fetch fails so the file is still wrapped — a
|
|
63
|
+
* test pins them equal to the server's, so they can't drift.
|
|
64
|
+
*/
|
|
65
|
+
export const FALLBACK_ENVELOPE_PREFIX = "async function __workflow(): Promise<__WorkflowReturn | void> {\n";
|
|
66
|
+
export const FALLBACK_ENVELOPE_SUFFIX = "\n}";
|
|
67
|
+
/**
|
|
68
|
+
* Header prepended to every pulled `src/workflows/<alias>.ts`. A triple-slash
|
|
69
|
+
* reference pulls in the per-alias ambient globals (`trigger` / `runtime` / tool
|
|
70
|
+
* calls — server-generated), and the body sits inside the SAME `__workflow`
|
|
71
|
+
* wrapper the server compiles within, so a local `tsc` now mirrors the set-time
|
|
72
|
+
* verdict (GAP-59). The wrapper + reference + comment lines are CLI bookkeeping,
|
|
73
|
+
* stripped on `set`; the filename IS the alias — renaming it orphans the body.
|
|
74
|
+
*/
|
|
75
|
+
function workflowFileHeader(alias) {
|
|
76
|
+
const refPath = path
|
|
77
|
+
.join("..", "..", WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`)
|
|
78
|
+
.split(path.sep)
|
|
79
|
+
.join("/");
|
|
80
|
+
return (`/// <reference path="${refPath}" />\n` +
|
|
81
|
+
`// Auto-pulled workflow body for "${alias}". Edit the BODY between the wrapper\n` +
|
|
82
|
+
`// lines below, then push with:\n` +
|
|
83
|
+
`// lotics app workflow set ${alias}\n` +
|
|
84
|
+
`// The push goes through set_app_workflow, where the SERVER verifies the body.\n` +
|
|
85
|
+
`// The __workflow wrapper + the reference above are CLI bookkeeping (stripped on\n` +
|
|
86
|
+
`// set) — they only make the body typecheck locally against the workspace types.\n` +
|
|
87
|
+
`// Do NOT rename this file — the filename is the alias the binding is keyed by.\n` +
|
|
88
|
+
// `export {};` makes the file a MODULE so the per-file `__workflow` wrapper
|
|
89
|
+
// doesn't collide across the bodies the dedicated tsconfig globs together.
|
|
90
|
+
// The server compiles each body in isolation (script mode), so this is a
|
|
91
|
+
// local-only adaptation with no effect on the body's type-checking; it is
|
|
92
|
+
// CLI bookkeeping, stripped on `set`.
|
|
93
|
+
`export {};\n`);
|
|
94
|
+
}
|
|
95
|
+
/** Absolute path of one workflow body file, given the project root + alias. */
|
|
96
|
+
function workflowFilePath(projectDir, alias) {
|
|
97
|
+
return path.join(projectDir, WORKFLOWS_DIR, `${alias}.ts`);
|
|
98
|
+
}
|
|
99
|
+
/** Absolute path of one alias's ambient globals `.d.ts`. */
|
|
100
|
+
function workflowGlobalsPath(projectDir, alias) {
|
|
101
|
+
return path.join(projectDir, WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Write `.lotics/workflows/<alias>.globals.d.ts` = the server-generated ambient
|
|
105
|
+
* declarations the body typechecks against. Idempotent. Returns the path.
|
|
106
|
+
*/
|
|
107
|
+
export function writeWorkflowGlobals(projectDir, alias, dts) {
|
|
108
|
+
const dir = path.join(projectDir, WORKFLOW_GLOBALS_DIR);
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
110
|
+
const file = workflowGlobalsPath(projectDir, alias);
|
|
111
|
+
fs.writeFileSync(file, `${dts.replace(/\s+$/, "")}\n`);
|
|
112
|
+
return file;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Write `src/workflows/<alias>.ts` = header (triple-slash reference + comments)
|
|
116
|
+
* + the body wrapped in the `__workflow` envelope. Idempotent (a re-pull
|
|
117
|
+
* overwrites with the current server body). The raw `source` round-trips on
|
|
118
|
+
* `set` (the header + wrapper are stripped). Exported for direct unit testing —
|
|
119
|
+
* the surrounding pull shells out to `tar`/`npm`.
|
|
120
|
+
*/
|
|
121
|
+
export function writeWorkflowFile(projectDir, alias, source, envelope = { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX }) {
|
|
122
|
+
const dir = path.join(projectDir, WORKFLOWS_DIR);
|
|
123
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
124
|
+
const file = workflowFilePath(projectDir, alias);
|
|
125
|
+
// Body verbatim inside the wrapper so a later `set` round-trips byte-for-byte
|
|
126
|
+
// (modulo the header + wrapper, which are stripped). The prefix already ends
|
|
127
|
+
// with a newline and the suffix begins with one (server envelope shape).
|
|
128
|
+
const body = source.replace(/\s+$/, "");
|
|
129
|
+
fs.writeFileSync(file, `${workflowFileHeader(alias)}\n${envelope.prefix}${body}${envelope.suffix}\n`);
|
|
130
|
+
return file;
|
|
131
|
+
}
|
|
132
|
+
/** Matches the `__workflow` envelope opener (the wrapper `writeWorkflowFile`
|
|
133
|
+
* prepends). Recognized structurally (not by exact return-type text) so a
|
|
134
|
+
* future envelope-signature tweak doesn't silently leave the wrapper in the
|
|
135
|
+
* pushed source. */
|
|
136
|
+
const WORKFLOW_WRAPPER_OPENER = /^\s*async\s+function\s+__workflow\s*\(/;
|
|
137
|
+
/**
|
|
138
|
+
* Strip the CLI bookkeeping back off a workflow body before pushing it: the
|
|
139
|
+
* triple-slash reference + the `//` header comments + the `export {};` marker +
|
|
140
|
+
* blank lines, then the `__workflow` envelope (the opening
|
|
141
|
+
* `async function __workflow(...) {` line and the matching trailing `}`). `set`
|
|
142
|
+
* sends ONLY the JS-subset body the author edited — the server stays the single
|
|
143
|
+
* verifier.
|
|
144
|
+
*
|
|
145
|
+
* The strip is anchored on the GENERATED bookkeeping, never on "the file happens
|
|
146
|
+
* to start with comments": the leading comment/marker/blank block is only peeled
|
|
147
|
+
* when it is immediately followed by the `__workflow` wrapper opener (the exact
|
|
148
|
+
* shape `writeWorkflowFile` produces). A hand-written, wrapper-LESS body whose
|
|
149
|
+
* first lines are comments therefore round-trips unchanged — its comments are
|
|
150
|
+
* real source, not bookkeeping, and must not be silently eaten.
|
|
151
|
+
*/
|
|
152
|
+
export function stripWorkflowHeader(content) {
|
|
153
|
+
const lines = content.split("\n");
|
|
154
|
+
// Probe how far a generated header would extend: `//`/`///` lines, then the
|
|
155
|
+
// `export {};` module marker, then the blank line(s) before the wrapper.
|
|
156
|
+
let headerEnd = 0;
|
|
157
|
+
while (headerEnd < lines.length && /^\s*\/\//.test(lines[headerEnd]))
|
|
158
|
+
headerEnd++;
|
|
159
|
+
while (headerEnd < lines.length && /^\s*export\s*\{\s*\}\s*;?\s*$/.test(lines[headerEnd]))
|
|
160
|
+
headerEnd++;
|
|
161
|
+
while (headerEnd < lines.length && lines[headerEnd].trim() === "")
|
|
162
|
+
headerEnd++;
|
|
163
|
+
// Peel the header ONLY when the wrapper opener follows it — that's the proof
|
|
164
|
+
// the leading block was generated bookkeeping and not the author's own source.
|
|
165
|
+
// If the file opens with the wrapper directly (degraded pull with no header),
|
|
166
|
+
// peel from there. Otherwise leave the body verbatim.
|
|
167
|
+
let start;
|
|
168
|
+
if (headerEnd < lines.length && WORKFLOW_WRAPPER_OPENER.test(lines[headerEnd])) {
|
|
169
|
+
start = headerEnd;
|
|
170
|
+
}
|
|
171
|
+
else if (lines.length > 0 && WORKFLOW_WRAPPER_OPENER.test(lines[0])) {
|
|
172
|
+
start = 0;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
return content.replace(/\s+$/, "");
|
|
176
|
+
}
|
|
177
|
+
// `start` is the wrapper opener line — drop it and the matching trailing `}`.
|
|
178
|
+
let end = lines.length - 1;
|
|
179
|
+
while (end > start && lines[end].trim() === "")
|
|
180
|
+
end--;
|
|
181
|
+
if (lines[end]?.trim() === "}") {
|
|
182
|
+
return lines.slice(start + 1, end).join("\n").replace(/\s+$/, "");
|
|
183
|
+
}
|
|
184
|
+
// Opener present but no closing `}` — a malformed wrapper; push the body after
|
|
185
|
+
// the opener verbatim rather than guessing where the envelope ends.
|
|
186
|
+
return lines.slice(start + 1).join("\n").replace(/\s+$/, "");
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Pull every workflow body the server can render into `src/workflows/<alias>.ts`,
|
|
190
|
+
* plus the per-alias ambient globals `.lotics/workflows/<alias>.globals.d.ts` that
|
|
191
|
+
* make the body locally typecheckable. The alias set comes from the live
|
|
192
|
+
* `apps.workflows` map; each body comes from `get_app_workflow` (faithful source
|
|
193
|
+
* re-rendered from the persisted step tree) and each globals + envelope from
|
|
194
|
+
* `getAppWorkflowDts`. A legacy alias whose source can't be read is WARNED and
|
|
195
|
+
* SKIPPED — never a failure — so a partially-migrated app still pulls. A dts
|
|
196
|
+
* fetch failure is non-fatal too: the body is still written (with the fallback
|
|
197
|
+
* envelope), only the local typecheck is degraded — mirrors `app_fields`.
|
|
198
|
+
* Returns the aliases written.
|
|
199
|
+
*/
|
|
200
|
+
async function writeWorkflowFiles(client, projectDir, app_id, aliases) {
|
|
201
|
+
const written = [];
|
|
202
|
+
for (const alias of aliases) {
|
|
203
|
+
const res = await client.getAppWorkflow(app_id, alias);
|
|
204
|
+
const source = res.error || res.result === null || typeof res.result !== "object"
|
|
205
|
+
? null
|
|
206
|
+
: res.result.source;
|
|
207
|
+
if (typeof source !== "string" || source.trim() === "") {
|
|
208
|
+
console.error(`⚠ Skipped src/workflows/${alias}.ts — the server returned no readable source ` +
|
|
209
|
+
`(${res.error ?? "legacy workflow with no rendered body"}).`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
|
|
213
|
+
writeWorkflowFile(projectDir, alias, source, envelope);
|
|
214
|
+
written.push(alias);
|
|
215
|
+
}
|
|
216
|
+
return written;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Fetch + write one alias's ambient globals `.d.ts` and return the wrapper
|
|
220
|
+
* envelope to wrap the body in. Network failure is non-fatal (warn, keep the
|
|
221
|
+
* last-written globals if any) and the body is wrapped in the FALLBACK envelope
|
|
222
|
+
* so it still parses — mirrors `app_fields`'s offline tolerance.
|
|
223
|
+
*/
|
|
224
|
+
async function fetchWorkflowGlobals(client, projectDir, app_id, alias) {
|
|
225
|
+
try {
|
|
226
|
+
const { dts, envelope_prefix, envelope_suffix } = await client.getAppWorkflowDts(app_id, alias);
|
|
227
|
+
writeWorkflowGlobals(projectDir, alias, dts);
|
|
228
|
+
return { prefix: envelope_prefix, suffix: envelope_suffix };
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
console.error(`⚠ Could not fetch workflow types for "${alias}" (${err instanceof Error ? err.message : String(err)}). ` +
|
|
232
|
+
`Wrote the body with the fallback wrapper; its local typecheck may be degraded.`);
|
|
233
|
+
return { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
49
236
|
/** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
|
|
50
237
|
function runTar(args, cwd) {
|
|
51
238
|
return new Promise((resolve, reject) => {
|
|
@@ -66,7 +253,7 @@ function runTar(args, cwd) {
|
|
|
66
253
|
/** Run `npm` (run/install/etc.) inheriting stdio so the user sees progress. */
|
|
67
254
|
function runNpm(args, cwd) {
|
|
68
255
|
return new Promise((resolve, reject) => {
|
|
69
|
-
const proc = spawn("npm", args, { cwd, stdio: "inherit" });
|
|
256
|
+
const proc = spawn("npm", args, { cwd, stdio: "inherit", env: ipv4ChildEnv(process.env) });
|
|
70
257
|
proc.on("error", reject);
|
|
71
258
|
proc.on("exit", (code) => {
|
|
72
259
|
if (code === 0)
|
|
@@ -113,9 +300,121 @@ function writeAppMeta(projectDir, meta) {
|
|
|
113
300
|
function writeAppDts(projectDir, manifest) {
|
|
114
301
|
const dotLotics = path.join(projectDir, ".lotics");
|
|
115
302
|
fs.mkdirSync(dotLotics, { recursive: true });
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
303
|
+
const written = [
|
|
304
|
+
[path.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows)],
|
|
305
|
+
[path.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries)],
|
|
306
|
+
[path.join(dotLotics, "app_agents.d.ts"), generateAppAgentsDts(manifest.agents)],
|
|
307
|
+
];
|
|
308
|
+
for (const [file, content] of written)
|
|
309
|
+
fs.writeFileSync(file, content);
|
|
310
|
+
return written.map(([file]) => file);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The optional `package.json#lotics.codegen.tables` allowlist — extra table ids
|
|
314
|
+
* to include in `app_fields.ts` beyond those the named queries reference (e.g.
|
|
315
|
+
* tables an app only writes to via a workflow, never queries). Returns `[]` when
|
|
316
|
+
* absent or malformed (codegen falls back to the query-derived set).
|
|
317
|
+
*/
|
|
318
|
+
function readCodegenTablesAllowlist(projectDir) {
|
|
319
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf-8"));
|
|
320
|
+
const tables = pkg.lotics?.codegen?.tables;
|
|
321
|
+
return Array.isArray(tables) ? tables.filter((t) => typeof t === "string") : [];
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The table ids `app_fields.ts` should cover: the union of every `from_table`
|
|
325
|
+
* referenced by a declared query AST plus the optional manifest allowlist. The
|
|
326
|
+
* query set is what the app can actually read; the allowlist widens it to tables
|
|
327
|
+
* only ever written (so their field/option aliases are still addressable).
|
|
328
|
+
*/
|
|
329
|
+
function resolveCodegenTableIds(projectDir, queries) {
|
|
330
|
+
const ids = new Set();
|
|
331
|
+
for (const decl of Object.values(queries)) {
|
|
332
|
+
// The manifest stores the AST untyped (`unknown`) — it's the server's
|
|
333
|
+
// deploy-time concern to verify. Cast at this single boundary so codegen
|
|
334
|
+
// reuses the canonical exhaustive walker (a new QueryNode kind is a
|
|
335
|
+
// compile error there, never a silently-dropped table).
|
|
336
|
+
for (const id of collectQueryTableIds(decl.ast))
|
|
337
|
+
ids.add(id);
|
|
338
|
+
}
|
|
339
|
+
for (const id of readCodegenTablesAllowlist(projectDir))
|
|
340
|
+
ids.add(id);
|
|
341
|
+
return [...ids];
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Write the runtime `.lotics/app_fields.ts` from a resolved workspace schema.
|
|
345
|
+
* Separate from `writeAppDts` (the type-only companions) because this one needs
|
|
346
|
+
* the client to fetch the schema. Returns the written path.
|
|
347
|
+
*/
|
|
348
|
+
function writeAppFields(projectDir, tables) {
|
|
349
|
+
const dotLotics = path.join(projectDir, ".lotics");
|
|
350
|
+
fs.mkdirSync(dotLotics, { recursive: true });
|
|
351
|
+
const file = path.join(dotLotics, "app_fields.ts");
|
|
352
|
+
fs.writeFileSync(file, generateAppFields(tables));
|
|
353
|
+
return file;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* `lotics app codegen [path]` — regenerate every `.lotics/` artifact from the
|
|
357
|
+
* manifest + workspace schema, WITHOUT a deploy. The `.d.ts` companions are
|
|
358
|
+
* always written (synchronous, no network). When a `client` is available, the
|
|
359
|
+
* runtime `app_fields.ts` is also regenerated from the live schema of the tables
|
|
360
|
+
* the app's queries reference (+ the allowlist); a network failure is non-fatal
|
|
361
|
+
* (warn, keep the last-generated file) — so codegen still does useful work
|
|
362
|
+
* offline, mirroring `app create`'s tolerance of an offline npm registry.
|
|
363
|
+
*/
|
|
364
|
+
export async function appCodegen(args) {
|
|
365
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
366
|
+
// readAppMeta throws the clear not-an-app error when there's no manifest.
|
|
367
|
+
const meta = readAppMeta(projectDir);
|
|
368
|
+
const dtsPaths = writeAppDts(projectDir, {
|
|
369
|
+
workflows: meta.workflows,
|
|
370
|
+
queries: meta.queries,
|
|
371
|
+
agents: meta.agents,
|
|
372
|
+
});
|
|
373
|
+
for (const p of dtsPaths)
|
|
374
|
+
console.error(`Regenerated ${p}`);
|
|
375
|
+
if (!args.client) {
|
|
376
|
+
console.error("Skipped .lotics/app_fields.ts — no workspace credentials resolved. " +
|
|
377
|
+
"Run authenticated (or set LOTICS_API_KEY) to regenerate field/option ids.");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const tableIds = resolveCodegenTableIds(projectDir, meta.queries ?? {});
|
|
381
|
+
try {
|
|
382
|
+
const tables = await args.client.getWorkspaceSchema(tableIds);
|
|
383
|
+
const fieldsPath = writeAppFields(projectDir, tables);
|
|
384
|
+
console.error(`Regenerated ${fieldsPath} (${tables.length} table${tables.length === 1 ? "" : "s"})`);
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
// Non-fatal: keep the last-generated app_fields.ts so an offline/transient
|
|
388
|
+
// failure doesn't strip the app's field aliases (same tolerance as the npm
|
|
389
|
+
// registry lookup on `app create`).
|
|
390
|
+
console.error(`⚠ Could not fetch the workspace schema (${err instanceof Error ? err.message : String(err)}). ` +
|
|
391
|
+
`Kept the existing .lotics/app_fields.ts.`);
|
|
392
|
+
}
|
|
393
|
+
// Refresh each bound workflow's ambient globals + re-wrap its EXISTING local
|
|
394
|
+
// body in the current envelope (GAP-59). Codegen never re-fetches the body
|
|
395
|
+
// (that would clobber local edits) — it strips the on-disk body and re-wraps
|
|
396
|
+
// it, so the local typecheck tracks the current workspace schema. Aliases
|
|
397
|
+
// never pulled (no body file yet) are skipped — codegen isn't a pull.
|
|
398
|
+
await refreshWorkflowGlobals(args.client, projectDir, meta.app_id, Object.keys(meta.workflows ?? {}));
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* For each bound alias that already has a local body file, fetch its current
|
|
402
|
+
* ambient globals `.d.ts` + envelope and re-wrap the on-disk body. Preserves the
|
|
403
|
+
* author's edits (strips + re-wraps, never re-fetches the body). A dts fetch
|
|
404
|
+
* failure is non-fatal per alias (warned inside `fetchWorkflowGlobals`).
|
|
405
|
+
*/
|
|
406
|
+
async function refreshWorkflowGlobals(client, projectDir, app_id, aliases) {
|
|
407
|
+
for (const alias of aliases) {
|
|
408
|
+
const file = workflowFilePath(projectDir, alias);
|
|
409
|
+
if (!fs.existsSync(file))
|
|
410
|
+
continue;
|
|
411
|
+
const body = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
|
|
412
|
+
if (body.trim() === "")
|
|
413
|
+
continue;
|
|
414
|
+
const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
|
|
415
|
+
writeWorkflowFile(projectDir, alias, body, envelope);
|
|
416
|
+
console.error(`Refreshed ${path.relative(projectDir, file)} + its workflow types`);
|
|
417
|
+
}
|
|
119
418
|
}
|
|
120
419
|
/**
|
|
121
420
|
* Stamp the post-extraction manifest with server-authoritative meta. Called
|
|
@@ -245,8 +544,10 @@ export async function appCreate(client, args) {
|
|
|
245
544
|
* 4. Regenerate `.lotics/app_workflows.d.ts` from the live workflows so
|
|
246
545
|
* `useWorkflow<"alias">` is typed at pull time.
|
|
247
546
|
* 5. Run `npm install`.
|
|
248
|
-
*
|
|
249
|
-
*
|
|
547
|
+
*
|
|
548
|
+
* Runtime field/option id aliases (`.lotics/app_fields.ts`) are generated on
|
|
549
|
+
* demand by `lotics app codegen`, which fetches the workspace schema — pull
|
|
550
|
+
* leaves it to that command so a schema fetch never blocks the bootstrap.
|
|
250
551
|
*/
|
|
251
552
|
/**
|
|
252
553
|
* `lotics app subdomain <new>` — rename the current app's public address.
|
|
@@ -306,6 +607,18 @@ export async function appPull(client, args) {
|
|
|
306
607
|
queries: app.queries ?? {},
|
|
307
608
|
agents: app.agents ?? {},
|
|
308
609
|
});
|
|
610
|
+
// Write each bound workflow's faithful body to src/workflows/<alias>.ts so the
|
|
611
|
+
// author edits a real file and pushes with `lotics app workflow set <alias>`
|
|
612
|
+
// — no more fetch/reconstruct/escape. Sourced from get_app_workflow (the live
|
|
613
|
+
// workflow row), like the manifest's `workflows` map, so agent-authored bodies
|
|
614
|
+
// survive the pull. A legacy alias with no rendered source warns and is skipped.
|
|
615
|
+
const aliases = Object.keys(app.workflows ?? {});
|
|
616
|
+
if (aliases.length > 0) {
|
|
617
|
+
const written = await writeWorkflowFiles(client, targetPath, app.id, aliases);
|
|
618
|
+
if (written.length > 0) {
|
|
619
|
+
console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
309
622
|
console.error(`Installing npm dependencies...`);
|
|
310
623
|
await runNpm(["install"], targetPath);
|
|
311
624
|
console.error(`\nReady. Next steps:`);
|
|
@@ -535,3 +848,297 @@ export async function appDev(client, args) {
|
|
|
535
848
|
console.error("\nStopping…");
|
|
536
849
|
await handle.stop();
|
|
537
850
|
}
|
|
851
|
+
/** Narrow the loose envelope's `created_records` into typed groups. */
|
|
852
|
+
function parseCreatedRecords(value) {
|
|
853
|
+
if (!Array.isArray(value))
|
|
854
|
+
return [];
|
|
855
|
+
const groups = [];
|
|
856
|
+
for (const entry of value) {
|
|
857
|
+
if (!entry || typeof entry !== "object")
|
|
858
|
+
continue;
|
|
859
|
+
const tableId = entry.table_id;
|
|
860
|
+
const recordIds = entry.record_ids;
|
|
861
|
+
if (typeof tableId !== "string" || !Array.isArray(recordIds))
|
|
862
|
+
continue;
|
|
863
|
+
const ids = recordIds.filter((id) => typeof id === "string");
|
|
864
|
+
if (ids.length > 0)
|
|
865
|
+
groups.push({ table_id: tableId, record_ids: ids });
|
|
866
|
+
}
|
|
867
|
+
return groups;
|
|
868
|
+
}
|
|
869
|
+
/** Narrow `irreversible_tool_calls` into a list of tool names (deduped, ordered). */
|
|
870
|
+
function parseIrreversibleToolNames(value) {
|
|
871
|
+
if (!Array.isArray(value))
|
|
872
|
+
return [];
|
|
873
|
+
const names = [];
|
|
874
|
+
for (const entry of value) {
|
|
875
|
+
if (!entry || typeof entry !== "object")
|
|
876
|
+
continue;
|
|
877
|
+
const name = entry.tool_name;
|
|
878
|
+
if (typeof name === "string" && !names.includes(name))
|
|
879
|
+
names.push(name);
|
|
880
|
+
}
|
|
881
|
+
return names;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Print the honest harvest of a run's side effects to stderr (GAP-58): created
|
|
885
|
+
* records grouped by table, a ready-to-paste `lotics run delete_records …` per
|
|
886
|
+
* table, then the MANDATORY caveat naming what cannot be auto-undone. Never
|
|
887
|
+
* deletes anything — this only reports.
|
|
888
|
+
*/
|
|
889
|
+
function printSideEffects(summary) {
|
|
890
|
+
const created = parseCreatedRecords(summary.created_records);
|
|
891
|
+
const irreversibleTools = parseIrreversibleToolNames(summary.irreversible_tool_calls);
|
|
892
|
+
const subWorkflows = summary.sub_workflows_possible === true;
|
|
893
|
+
console.error("\nCreated records:");
|
|
894
|
+
if (created.length === 0) {
|
|
895
|
+
console.error(" (none with ids to clean up)");
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
for (const group of created) {
|
|
899
|
+
console.error(` ${group.table_id}: ${group.record_ids.length} record(s)`);
|
|
900
|
+
const payload = JSON.stringify({ table_id: group.table_id, record_ids: group.record_ids });
|
|
901
|
+
console.error(` lotics run delete_records '${payload}'`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// The caveat is mandatory and unconditional — a clean run still owes the
|
|
905
|
+
// reader the explicit "this is not a rollback" framing so cleanup is never
|
|
906
|
+
// mistaken for complete.
|
|
907
|
+
const irreversiblePart = irreversibleTools.length > 0
|
|
908
|
+
? `Could NOT auto-undo (clean up manually): ${irreversibleTools.join(", ")}.`
|
|
909
|
+
: "Could NOT auto-undo: none.";
|
|
910
|
+
const subPart = subWorkflows
|
|
911
|
+
? " Sub-workflows may have run (after_* table workflows) — their effects are NOT in this list."
|
|
912
|
+
: "";
|
|
913
|
+
console.error(`\n${irreversiblePart}${subPart}`);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Run the harvested deletes for created records ONLY (never files / external /
|
|
917
|
+
* notifications — those are reported, never silently undone). Best-effort: a
|
|
918
|
+
* failed delete is logged and the rest continue. Returns `false` when ANY delete
|
|
919
|
+
* failed, so the command boundary can exit non-zero — a CI script branching on
|
|
920
|
+
* the exit code must not read partial cleanup as success.
|
|
921
|
+
*/
|
|
922
|
+
async function cleanupCreatedRecords(client, created) {
|
|
923
|
+
if (created.length === 0) {
|
|
924
|
+
console.error("\nNo created records to clean up.");
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
console.error("\nCleaning up created records (delete_records — records only):");
|
|
928
|
+
let allDeleted = true;
|
|
929
|
+
for (const group of created) {
|
|
930
|
+
const res = await client.execute("delete_records", {
|
|
931
|
+
table_id: group.table_id,
|
|
932
|
+
record_ids: group.record_ids,
|
|
933
|
+
});
|
|
934
|
+
if (res.error) {
|
|
935
|
+
console.error(` ✗ ${group.table_id}: ${res.error}`);
|
|
936
|
+
allDeleted = false;
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
console.error(` ✓ ${group.table_id}: deleted ${group.record_ids.length} record(s)`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return allDeleted;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* `lotics app workflow run <alias> '<json>'` — execute a bound app workflow
|
|
946
|
+
* end-to-end against the live workspace. `app_id` comes from the local manifest
|
|
947
|
+
* (like deploy/dev), the alias must be bound server-side via `set_app_workflow`.
|
|
948
|
+
*
|
|
949
|
+
* The full `{ status, message, data, files, side_effects }` JSON prints to
|
|
950
|
+
* stdout (pipeable / assertable); a one-line human summary goes to stderr. A
|
|
951
|
+
* `status: "error"` envelope exits non-zero so a script can branch on it — the
|
|
952
|
+
* transport already normalizes a gateway/timeout failure into the same
|
|
953
|
+
* `{ status: "error" }` shape, so a failed run is never a thrown HTML body.
|
|
954
|
+
*
|
|
955
|
+
* `--print-created` (alias `--report-effects`) renders the honest post-run
|
|
956
|
+
* harvest (GAP-58): created records grouped by table, a paste-ready
|
|
957
|
+
* `delete_records` per table, and the mandatory caveat about what cannot be
|
|
958
|
+
* auto-undone. `--cleanup` (DEFAULT OFF) additionally runs the deletes for the
|
|
959
|
+
* harvested records ONLY — never files, external integrations, or notifications.
|
|
960
|
+
* Neither is a rollback; a rollback is structurally impossible here.
|
|
961
|
+
*/
|
|
962
|
+
export async function appExecuteWorkflow(client, args) {
|
|
963
|
+
const meta = readAppMeta(process.cwd());
|
|
964
|
+
const result = (await client.appWorkflow(meta.app_id, args.alias, args.inputs));
|
|
965
|
+
console.log(JSON.stringify(result, null, 2));
|
|
966
|
+
const status = typeof result.status === "string" ? result.status : "unknown";
|
|
967
|
+
const message = typeof result.message === "string" ? result.message : "";
|
|
968
|
+
console.error(`Workflow "${args.alias}" → ${status}${message ? `: ${message}` : ""}`);
|
|
969
|
+
// --cleanup implies the report (you should always see what's being undone).
|
|
970
|
+
let cleanupFailed = false;
|
|
971
|
+
if ((args.printCreated || args.cleanup) && result.side_effects) {
|
|
972
|
+
printSideEffects(result.side_effects);
|
|
973
|
+
if (args.cleanup) {
|
|
974
|
+
const allDeleted = await cleanupCreatedRecords(client, parseCreatedRecords(result.side_effects.created_records));
|
|
975
|
+
cleanupFailed = !allDeleted;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
else if (args.printCreated || args.cleanup) {
|
|
979
|
+
console.error("\n(no side-effect summary returned by the server)");
|
|
980
|
+
}
|
|
981
|
+
// Exit non-zero on an error run OR a partial cleanup — a script must not read
|
|
982
|
+
// either as success.
|
|
983
|
+
if (status === "error" || cleanupFailed)
|
|
984
|
+
process.exit(1);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* `lotics app workflow set <alias>` — push the edited `src/workflows/<alias>.ts`
|
|
988
|
+
* body to the server through `set_app_workflow` (the single author of
|
|
989
|
+
* `apps.workflows`). The body is read from disk (header stripped); the typed
|
|
990
|
+
* `inputs`/`outputs` schemas come from `package.json#lotics.workflows.<alias>`,
|
|
991
|
+
* so a pulled-then-edited app keeps its declared contract. The server re-verifies
|
|
992
|
+
* the body and echoes the bound `outputs` (declared, else DERIVED from
|
|
993
|
+
* `return({ data })`) — the same guarantee as calling `set_app_workflow` by hand,
|
|
994
|
+
* with no fetch/reconstruct/escape. Errors (missing file, unbound alias, verify
|
|
995
|
+
* failure) print to stderr and exit non-zero.
|
|
996
|
+
*
|
|
997
|
+
* This is a CLI convenience over the existing tool — `lotics app deploy` is still
|
|
998
|
+
* NOT an author of workflows; the single-author invariant holds.
|
|
999
|
+
*/
|
|
1000
|
+
export async function appWorkflowSet(client, args) {
|
|
1001
|
+
const projectDir = process.cwd();
|
|
1002
|
+
const meta = readAppMeta(projectDir);
|
|
1003
|
+
const declaration = meta.workflows?.[args.alias];
|
|
1004
|
+
if (!declaration) {
|
|
1005
|
+
console.error(`No workflow "${args.alias}" in package.json#lotics.workflows. ` +
|
|
1006
|
+
`Bind it first (set_app_workflow), then 'lotics app pull' to write its body and manifest entry.`);
|
|
1007
|
+
process.exit(1);
|
|
1008
|
+
}
|
|
1009
|
+
const file = workflowFilePath(projectDir, args.alias);
|
|
1010
|
+
if (!fs.existsSync(file)) {
|
|
1011
|
+
console.error(`No workflow body at ${path.relative(projectDir, file)}. ` +
|
|
1012
|
+
`Run 'lotics app pull ${meta.app_id}' to write src/workflows/${args.alias}.ts, then edit it.`);
|
|
1013
|
+
process.exit(1);
|
|
1014
|
+
}
|
|
1015
|
+
const source = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
|
|
1016
|
+
if (source.trim() === "") {
|
|
1017
|
+
console.error(`Workflow body ${path.relative(projectDir, file)} is empty after stripping the header.`);
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
}
|
|
1020
|
+
const res = await client.setAppWorkflow(meta.app_id, args.alias, {
|
|
1021
|
+
source,
|
|
1022
|
+
inputs: declaration.inputs,
|
|
1023
|
+
outputs: declaration.outputs,
|
|
1024
|
+
});
|
|
1025
|
+
if (res.error) {
|
|
1026
|
+
console.error(`Failed to set workflow "${args.alias}": ${res.error}`);
|
|
1027
|
+
process.exit(1);
|
|
1028
|
+
}
|
|
1029
|
+
// set_app_workflow echoes { app_id, alias, workflow_id, outputs? } — outputs is
|
|
1030
|
+
// declared-wins-else-DERIVED from return({ data }), the shape result.data carries.
|
|
1031
|
+
const result = (res.result ?? {});
|
|
1032
|
+
const workflowId = typeof result.workflow_id === "string" ? result.workflow_id : "(unknown)";
|
|
1033
|
+
console.error(`Set workflow "${args.alias}" → ${workflowId}`);
|
|
1034
|
+
if (result.outputs && typeof result.outputs === "object") {
|
|
1035
|
+
console.error(` result.data schema: ${JSON.stringify(result.outputs)}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* `lotics app workflow pull` — rewrite every `src/workflows/<alias>.ts` from the
|
|
1040
|
+
* server without a full `lotics app pull` (no source archive, no npm install).
|
|
1041
|
+
* The alias set + bodies come from the live App row (the same source `app pull`
|
|
1042
|
+
* uses); a legacy alias with no rendered source warns and is skipped.
|
|
1043
|
+
*/
|
|
1044
|
+
export async function appWorkflowPull(client) {
|
|
1045
|
+
const projectDir = process.cwd();
|
|
1046
|
+
const meta = readAppMeta(projectDir);
|
|
1047
|
+
const app = await client.getApp(meta.app_id);
|
|
1048
|
+
const aliases = Object.keys(app.workflows ?? {});
|
|
1049
|
+
if (aliases.length === 0) {
|
|
1050
|
+
console.error(`App ${meta.app_id} has no bound workflows.`);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const written = await writeWorkflowFiles(client, projectDir, meta.app_id, aliases);
|
|
1054
|
+
console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` +
|
|
1055
|
+
(written.length > 0 ? ` (${written.join(", ")})` : ""));
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Walk up from `start` to the monorepo's `packages/ui/src`. Returns null when
|
|
1059
|
+
* not found — an external npm app author has no monorepo checkout, so `ui link`
|
|
1060
|
+
* must fail loud rather than write a broken alias.
|
|
1061
|
+
*/
|
|
1062
|
+
function findUiSrcDir(start) {
|
|
1063
|
+
let dir = path.resolve(start);
|
|
1064
|
+
for (;;) {
|
|
1065
|
+
const candidate = path.join(dir, "packages", "ui", "src");
|
|
1066
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory())
|
|
1067
|
+
return candidate;
|
|
1068
|
+
const parent = path.dirname(dir);
|
|
1069
|
+
if (parent === dir)
|
|
1070
|
+
return null;
|
|
1071
|
+
dir = parent;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// The dev-link alias entry — a package-wide `@lotics/ui/<subpath>` → local
|
|
1075
|
+
// `packages/ui/src/<subpath>` redirect. Matched/removed by the literal `find`
|
|
1076
|
+
// regex source so insert/remove is idempotent regardless of the replacement.
|
|
1077
|
+
const UI_ALIAS_FIND_SOURCE = String.raw `/^@lotics\/ui\/(.+)$/`;
|
|
1078
|
+
/**
|
|
1079
|
+
* `lotics ui link <component> [--remove]` — add or remove the `@lotics/ui`
|
|
1080
|
+
* dev-link alias in the app's `vite.config.ts`, so edits to the monorepo's
|
|
1081
|
+
* `packages/ui/src` go live (HMR) without a publish round-trip. `component` is
|
|
1082
|
+
* advisory only — the alias is package-wide (one subpath regex covers every
|
|
1083
|
+
* import); it's validated to exist under `packages/ui/src` so a typo fails here.
|
|
1084
|
+
*
|
|
1085
|
+
* Idempotent: linking twice is a no-op; `--remove` strips the one inserted
|
|
1086
|
+
* entry and leaves the rest of `resolve.alias` intact.
|
|
1087
|
+
*/
|
|
1088
|
+
export function appUiLink(args) {
|
|
1089
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
1090
|
+
const viteConfigPath = path.join(projectDir, "vite.config.ts");
|
|
1091
|
+
if (!fs.existsSync(viteConfigPath)) {
|
|
1092
|
+
throw new Error(`No vite.config.ts at ${projectDir}. Run inside a 'lotics app' project directory.`);
|
|
1093
|
+
}
|
|
1094
|
+
const uiSrc = findUiSrcDir(projectDir);
|
|
1095
|
+
if (!uiSrc) {
|
|
1096
|
+
throw new Error("Cannot find packages/ui/src by walking up from this directory — `lotics ui link` " +
|
|
1097
|
+
"requires a monorepo checkout. External apps consume @lotics/ui from npm; bump the " +
|
|
1098
|
+
"package version and widen the app's dependency range instead.");
|
|
1099
|
+
}
|
|
1100
|
+
// Validate the named component exists in src so a typo fails loud (the alias
|
|
1101
|
+
// itself stays package-wide — this is the advisory check the spec calls for).
|
|
1102
|
+
const hasComponent = fs.existsSync(path.join(uiSrc, `${args.component}.tsx`)) ||
|
|
1103
|
+
fs.existsSync(path.join(uiSrc, `${args.component}.ts`)) ||
|
|
1104
|
+
fs.existsSync(path.join(uiSrc, args.component));
|
|
1105
|
+
if (!hasComponent) {
|
|
1106
|
+
throw new Error(`No '@lotics/ui/${args.component}' under ${uiSrc} (expected ${args.component}.tsx/.ts). ` +
|
|
1107
|
+
`Check the component name.`);
|
|
1108
|
+
}
|
|
1109
|
+
const source = fs.readFileSync(viteConfigPath, "utf-8");
|
|
1110
|
+
const aliasEntry = `{ find: ${UI_ALIAS_FIND_SOURCE}, replacement: ${JSON.stringify(`${uiSrc}/$1`)} },`;
|
|
1111
|
+
const alreadyLinked = source.includes(UI_ALIAS_FIND_SOURCE);
|
|
1112
|
+
if (args.remove) {
|
|
1113
|
+
if (!alreadyLinked) {
|
|
1114
|
+
console.error("No @lotics/ui dev-link alias present — nothing to remove.");
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
// Drop the whole alias line (the entry + its own line), leaving the rest of
|
|
1118
|
+
// resolve.alias untouched.
|
|
1119
|
+
const stripped = source.replace(new RegExp(`^\\s*\\{ find: ${escapeRegExp(UI_ALIAS_FIND_SOURCE)}.*$\\n?`, "m"), "");
|
|
1120
|
+
fs.writeFileSync(viteConfigPath, stripped);
|
|
1121
|
+
console.error(`Removed the @lotics/ui dev-link alias from ${viteConfigPath}.`);
|
|
1122
|
+
console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (alreadyLinked) {
|
|
1126
|
+
console.error(`@lotics/ui is already dev-linked in ${viteConfigPath}.`);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const aliasMatch = /resolve\s*:\s*\{[\s\S]*?alias\s*:\s*\[/.exec(source);
|
|
1130
|
+
if (!aliasMatch) {
|
|
1131
|
+
throw new Error(`Could not find a resolve.alias array literal in ${viteConfigPath}. ` +
|
|
1132
|
+
`Refresh vite.config.ts from the starter (packages/sdk/src/starter_template.ts) and retry.`);
|
|
1133
|
+
}
|
|
1134
|
+
const insertAt = aliasMatch.index + aliasMatch[0].length;
|
|
1135
|
+
const updated = `${source.slice(0, insertAt)}\n ${aliasEntry}${source.slice(insertAt)}`;
|
|
1136
|
+
fs.writeFileSync(viteConfigPath, updated);
|
|
1137
|
+
console.error(`Dev-linked @lotics/ui → ${uiSrc} in ${viteConfigPath}.`);
|
|
1138
|
+
console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
|
|
1139
|
+
console.error("Finalize: PR the packages/ui change → publish → `lotics ui link <component> --remove` + bump the app's dep.");
|
|
1140
|
+
}
|
|
1141
|
+
/** Escape a string for literal use inside a RegExp. */
|
|
1142
|
+
function escapeRegExp(s) {
|
|
1143
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1144
|
+
}
|