@dforge-core/dforge-mcp 0.2.45 → 0.2.47

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -1
  3. package/dist/server.js +71 -3
  4. package/package.json +3 -3
  5. package/resources/docs/dsl-reference.md +1 -1
  6. package/resources/schemas/data-views.schema.json +50 -0
  7. package/scripts/install-skills.mjs +1 -0
  8. package/skills/dforge-1c-import/SKILL.md +375 -0
  9. package/skills/dforge-1c-import/references/environment.md +98 -0
  10. package/skills/dforge-1c-import/references/loading.md +164 -0
  11. package/skills/dforge-1c-import/references/measurements.md +210 -0
  12. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  13. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  14. package/skills/dforge-1c-import/scripts/README.md +164 -0
  15. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  16. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  17. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  18. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  19. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  20. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  21. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  22. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  23. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  24. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  25. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  26. package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
  27. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  28. package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
  29. package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
  30. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  31. package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  33. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  34. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  35. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  36. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  37. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  38. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  39. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  40. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  41. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  42. package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
  43. package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
  44. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  45. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  46. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  47. package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
  48. package/skills/dforge-1c-import/scripts/lib/peek.py +68 -0
  49. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  50. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  51. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  52. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  53. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  54. package/skills/dforge-1c-import/scripts/lib/recorders.py +91 -0
  55. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  56. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  57. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  58. package/skills/dforge-1c-import/scripts/lib/seedstate.py +493 -0
  59. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  60. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  61. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  62. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  63. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  64. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  65. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  66. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  67. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  68. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  69. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  70. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  71. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  72. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  73. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  74. package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
  75. package/skills/dforge-mcp-author/SKILL.md +5 -0
  76. package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
  77. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  78. package/skills/dforge-mcp-author/references/formulas.md +32 -1
  79. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  80. package/skills/dforge-mcp-author/references/queries.md +17 -2
  81. package/skills/dforge-mcp-author/references/registers.md +1 -0
  82. package/skills/dforge-mcp-author/references/reports.md +1 -1
  83. package/skills/dforge-mcp-author/references/validation-checklist.md +1 -0
@@ -0,0 +1,326 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Bring an MS SQL export up to date with what 1C changed since it was taken, reading only
4
+ the change: `dump1c data --changes`. A full export of a large infobase is an hour and a
5
+ half (313 M rows); the change of a few days is minutes, and the delta loader reads the
6
+ export either way.
7
+
8
+ python 1c.py dump1c data --changes # since the export's own mark
9
+ python 1c.py dump1c data --changes --since 2026-09-17 # as if exported that day
10
+ python 1c.py dump1c data --changes --since 0x... # from a given rowversion
11
+
12
+ What 1C gives to detect change is the same as `delta` reads, decided per TABLE here from
13
+ the schema dump alone - the export knows nothing of the column map:
14
+
15
+ object `_IDRRef` and a `_Version` rowversion (catalogs, documents, charts):
16
+ the rows at or above the mark replace theirs; a key 1C no longer holds
17
+ is a deletion. 1C writes an object whole, so this is the whole of its change.
18
+ lines a tabular part, `_Document12_VT34`, owned through `_Document12_IDRRef`:
19
+ the lines of a changed owner are replaced, those of a vanished one dropped.
20
+ movements a register with `_RecorderRRef`: the movements of a changed recorder are
21
+ replaced, read through the recorder index; those of a vanished one dropped.
22
+ With `_RecorderTRef` only the recorder types the register holds are asked.
23
+ whole anything else - an independent information register, totals, constants:
24
+ read again in full.
25
+
26
+ The mark is the export's oldest horizon (or `--since`); the new one is
27
+ `MIN_ACTIVE_ROWVERSION()` taken before anything is read, so a change made during the run
28
+ is read again by the next one, never lost. Each table's file is rebuilt in place - what
29
+ it held, less every replaced or deleted key, plus the fresh rows - so after a run the
30
+ export is what a full one would have been, and `export.json` keeps the run in `changes`.
31
+
32
+ A table whose columns changed (a configuration update) is read again in full: its old
33
+ rows no longer have the shape the new ones do. A row changed below the mark is not found:
34
+ the mark has to be one every earlier read was taken after.
35
+ """
36
+ from __future__ import print_function
37
+
38
+ import datetime
39
+ import os
40
+ import re
41
+ import sys
42
+ import time
43
+
44
+ import dump1c
45
+
46
+ OBJECT = re.compile(r"^_(Document|Reference|Acc|Chrc|CKinds|Node|BPr|Task)\d+$")
47
+ OWNER = re.compile(r"^(_(?:Document|Reference|Acc|Chrc|CKinds|Node|BPr|Task)\d+)_IDRRef$")
48
+ KEYS_IN_LIST = 500
49
+ # Registers whose entries 1C switches on and off without writing the recorder.
50
+ ACTIVE = re.compile(r"^_AccRg\d+$")
51
+
52
+
53
+ def hexed(raw):
54
+ return "0x" + bytes(raw).hex()
55
+
56
+
57
+ def classify(name, columns, schema):
58
+ """(kind, detail) for one table, from its columns and its neighbours' - see the docstring."""
59
+ by = dict((c["column"], c) for c in columns)
60
+ versioned = (by.get("_Version") or {}).get("type") == "timestamp"
61
+ if versioned and "_IDRRef" in by:
62
+ return "object", None
63
+ for column in by:
64
+ match = OWNER.match(column)
65
+ if match and name.startswith(match.group(1) + "_") and match.group(1) in schema:
66
+ return "lines", (column, match.group(1))
67
+ if "_RecorderRRef" in by:
68
+ return "movements", "_RecorderTRef" in by
69
+ return "whole", None
70
+
71
+
72
+ class Objects(object):
73
+ """Every object key 1C holds, and which of them changed at or after the mark.
74
+
75
+ Two reads per table rather than one: the key alone comes off the narrowest index, while
76
+ `_Version` lives only in the clustered one - measured on 2.7 M keys, 90 s for both
77
+ together, a few seconds apart.
78
+ """
79
+
80
+ def __init__(self, source, schema, mark):
81
+ started = time.time()
82
+ self.alive, self.changed = {}, {}
83
+ for name, (columns, _) in sorted(schema.items()):
84
+ if classify(name, columns, schema)[0] != "object":
85
+ continue
86
+ self.changed[name] = [bytes(k) for k in source.column(
87
+ "SELECT _IDRRef FROM [%s] WHERE _Version >= %s" % (name, hexed(mark)))]
88
+ self.alive[name] = [bytes(k) for k in source.column("SELECT _IDRRef FROM [%s]" % name)]
89
+ self.seconds = time.time() - started
90
+ self._arrow = {}
91
+
92
+ def alive_of(self, name=None):
93
+ """The live keys of one object table - or of every one, for recorders - as Arrow,
94
+ built once: 2.7 M keys per register otherwise."""
95
+ import pyarrow as pa
96
+ if name not in self._arrow:
97
+ keys = self.alive[name] if name else [k for ks in self.alive.values() for k in ks]
98
+ self._arrow[name] = pa.array(keys, type=pa.binary())
99
+ return self._arrow[name]
100
+
101
+ def all_changed(self):
102
+ return [k for keys in self.changed.values() for k in keys]
103
+
104
+
105
+ def since(source, schema, day):
106
+ """A mark for a run as if the export had been taken on `day`: the lowest `_Version` of
107
+ any document dated on or after it. Everything 1C wrote from about then on is above it."""
108
+ if day.startswith("0x"):
109
+ return bytes.fromhex(day[2:])
110
+ shifted = "%04d-%s" % (int(day[:4]) + source.year_offset, day[5:])
111
+ lowest = None
112
+ for name, (columns, _) in schema.items():
113
+ by = dict((c["column"], c) for c in columns)
114
+ if not name.startswith("_Document") or not OBJECT.match(name) or "_Date_Time" not in by \
115
+ or (by.get("_Version") or {}).get("type") != "timestamp":
116
+ continue
117
+ found = source.column("SELECT MIN(_Version) FROM [%s] WHERE _Date_Time >= '%s'" % (name, shifted))
118
+ if found and found[0] is not None and (lowest is None or bytes(found[0]) < lowest):
119
+ lowest = bytes(found[0])
120
+ if lowest is None:
121
+ raise SystemExit("changes: no document dated on or after %s" % day)
122
+ return lowest
123
+
124
+
125
+ def in_lists(column, keys):
126
+ for at in range(0, len(keys), KEYS_IN_LIST):
127
+ yield "%s IN (%s)" % (column, ", ".join(hexed(k) for k in keys[at:at + KEYS_IN_LIST]))
128
+
129
+
130
+ def recorder_type(source, register, path, objects, cache={}):
131
+ """The one table a TRef-less register's recorders come from, by one recorder it holds."""
132
+ import duckdb
133
+ con = duckdb.connect()
134
+ sample = con.execute("SELECT _RecorderRRef FROM read_parquet(?) LIMIT 1", [path]).fetchone()
135
+ con.close()
136
+ if not sample:
137
+ return None
138
+ key = bytes(sample[0])
139
+ if key not in cache:
140
+ cache[key] = next((name for name in sorted(objects.alive, key=lambda n: not n.startswith("_Document"))
141
+ if source.column("SELECT COUNT(*) FROM [%s] WHERE _IDRRef = %s"
142
+ % (name, hexed(key)))[0]), None)
143
+ return cache[key]
144
+
145
+
146
+ class Plan(object):
147
+ """What one table's rebuild reads fresh and which of its old rows it drops."""
148
+
149
+ def __init__(self, name, kind):
150
+ self.name, self.kind = name, kind
151
+ self.wheres = [] # T-SQL conditions; each one query against 1C
152
+ self.column = None # the key column old rows are judged by
153
+ self.replaced = [] # old rows with these keys go: they are read fresh
154
+ self.alive = None # old rows whose key is not among these go: 1C deleted them
155
+ self.whole = False
156
+ self.owners = 0
157
+ self.toggled = 0 # recorders read again because their entries were switched
158
+ self.switched = {} # {recorder table number: [key hex]} of those, for `delta`
159
+
160
+
161
+ def plan(source, name, columns, schema, objects, path):
162
+ kind, detail = classify(name, columns, schema)
163
+ p = Plan(name, kind)
164
+ if kind == "object":
165
+ p.column, p.replaced, p.alive = "_IDRRef", objects.changed[name], objects.alive_of(name)
166
+ p.wheres = list(in_lists("_IDRRef", p.replaced))
167
+ elif kind == "lines":
168
+ p.column, owner = detail
169
+ p.replaced, p.alive = objects.changed[owner], objects.alive_of(owner)
170
+ p.wheres = list(in_lists(p.column, p.replaced))
171
+ elif kind == "movements":
172
+ p.column, p.alive = "_RecorderRRef", objects.alive_of()
173
+ if detail:
174
+ # Only the recorder types this register holds, off the recorder index.
175
+ numbers = set(int.from_bytes(bytes(t), "big") for t in
176
+ source.column("SELECT DISTINCT _RecorderTRef FROM [%s]" % name))
177
+ for number in sorted(numbers):
178
+ keys = objects.changed.get("_Document%d" % number) or []
179
+ p.replaced += keys
180
+ p.wheres += ["_RecorderTRef = 0x%08x AND %s" % (number, c)
181
+ for c in in_lists("_RecorderRRef", keys)]
182
+ else:
183
+ owner = recorder_type(source, name, path, objects) if os.path.exists(path) else None
184
+ keys = objects.changed[owner] if owner else objects.all_changed()
185
+ p.replaced = keys
186
+ p.wheres = list(in_lists("_RecorderRRef", keys))
187
+ else:
188
+ p.whole = True
189
+ if kind == "movements" and ACTIVE.match(name) and os.path.exists(path) \
190
+ and set(["_Active", "_RecorderTRef"]) <= set(c["column"] for c in columns):
191
+ already = set(p.replaced)
192
+ found = [(n, k) for n, k in switched(source, name, path) if k not in already]
193
+ toggled = [k for _, k in found]
194
+ p.replaced = p.replaced + toggled
195
+ p.wheres = p.wheres + list(in_lists("_RecorderRRef", toggled))
196
+ p.toggled = len(toggled)
197
+ # `delta` finds a changed recorder by `_Version` too, so it is told these by name.
198
+ for number, key in found:
199
+ p.switched.setdefault(str(number), []).append(key.hex())
200
+ p.owners = len(p.replaced)
201
+ return p
202
+
203
+
204
+ def switched(source, name, path):
205
+ """Recorders whose entries were switched on or off since the export.
206
+
207
+ `ПереключитьАктивностьПроводок` (the journal's "switch entries on/off") writes a
208
+ recorder's entry set again with another `_Active` and leaves the document alone, so
209
+ its `_Version` does not move and nothing else here would see it. The count of inactive
210
+ entries per recorder, 1C's against the export's, does: a scan of the register, which on
211
+ the ledger of 24 M entries is seconds, and only accounting registers have the command.
212
+ """
213
+ import duckdb
214
+ now = dict(((int.from_bytes(bytes(t), "big"), bytes(k)), n) for t, k, n in source.triples(
215
+ "SELECT _RecorderTRef, _RecorderRRef, COUNT(*) FROM [%s] WHERE _Active = 0x00"
216
+ " GROUP BY _RecorderTRef, _RecorderRRef" % name))
217
+ con = duckdb.connect()
218
+ held = dict(((int.from_bytes(bytes(t), "big"), bytes(k)), n) for t, k, n in con.execute(
219
+ "SELECT _RecorderTRef, _RecorderRRef, COUNT(*) FROM read_parquet(?)"
220
+ " WHERE _Active = from_hex('00') GROUP BY _RecorderTRef, _RecorderRRef", [path]).fetchall())
221
+ con.close()
222
+ return sorted(r for r in set(now) | set(held) if now.get(r) != held.get(r))
223
+
224
+
225
+ def same_shape(path, arrow):
226
+ """Do the file's columns still have the names and types the table has now?"""
227
+ import pyarrow.parquet as pq
228
+ if not os.path.exists(path):
229
+ return False
230
+ held = pq.read_schema(path)
231
+ return [(f.name, f.type) for f in held] == [(f.name, f.type) for f in arrow]
232
+
233
+
234
+ def rebuild(source, p, columns, path):
235
+ """Old rows less the replaced and the deleted, plus the fresh: the file, rewritten."""
236
+ import duckdb
237
+ import pyarrow as pa
238
+ arrow = dump1c.arrow_schema(columns)
239
+ names = [c["column"] for c in columns]
240
+ fresh = [dump1c.arrow_batch(arrow, columns, batch)
241
+ for where in p.wheres for batch in source.rows(p.name, names, dump1c.CHUNK, where)]
242
+ fresh = pa.concat_tables(fresh) if fresh else arrow.empty_table()
243
+ con = duckdb.connect()
244
+ con.register("fresh", fresh)
245
+ con.register("replaced", pa.table({"k": pa.array(p.replaced, type=pa.binary())}))
246
+ con.register("alive", pa.table({"k": p.alive}))
247
+ key = '"%s"' % p.column
248
+ kept_sql = ("SELECT * FROM read_parquet(?) o WHERE o.%s IN (SELECT k FROM alive)"
249
+ " AND o.%s NOT IN (SELECT k FROM replaced)" % (key, key))
250
+ before = con.execute("SELECT COUNT(*) FROM read_parquet(?)", [path]).fetchone()[0]
251
+ kept = con.execute("SELECT COUNT(*) FROM (%s)" % kept_sql, [path]).fetchone()[0]
252
+ if not fresh.num_rows and kept == before:
253
+ # Nothing to replace and nothing deleted: the file stands. On a ledger of 100 M
254
+ # rows that is the difference between a lookup and a rewrite.
255
+ con.close()
256
+ return {"fresh": 0, "dropped": 0, "rows": before}
257
+ tmp = path + ".tmp"
258
+ con.execute("COPY (%s UNION ALL SELECT * FROM fresh) TO '%s' (FORMAT PARQUET, COMPRESSION ZSTD)"
259
+ % (kept_sql, tmp.replace("\\", "/").replace("'", "''")), [path])
260
+ con.close()
261
+ os.replace(tmp, path)
262
+ return {"fresh": fresh.num_rows, "dropped": before - kept, "rows": kept + fresh.num_rows}
263
+
264
+
265
+ def run(source, held, schema, args, whole_table):
266
+ """`dump1c data --changes`. `whole_table(name)` re-exports one table in full."""
267
+ if source.kind != "mssql":
268
+ raise SystemExit("data --changes: a file infobase has no rowversion to read changes by"
269
+ " - export it again in full (`dump1c data`)")
270
+ exported = sorted(held["tables"])
271
+ if not exported:
272
+ raise SystemExit("data --changes: nothing exported yet - run `dump1c data` first")
273
+ marks = [held["tables"][t].get("horizon") for t in exported]
274
+ if args.since:
275
+ mark = since(source, schema, args.since)
276
+ elif any(m is None for m in marks):
277
+ raise SystemExit("data --changes: a table was exported without a horizon - use --since")
278
+ else:
279
+ mark = bytes.fromhex(min(marks))
280
+ now = source.horizon()
281
+ started = time.time()
282
+ objects = Objects(source, schema, mark)
283
+ changed = sum(len(k) for k in objects.changed.values())
284
+ print("mark %s, horizon %s: %d object(s) changed, %d alive, read in %.1fs"
285
+ % (hexed(mark), hexed(now), changed, len(objects.alive_of()), objects.seconds))
286
+
287
+ summary, totals = {}, {}
288
+ for at, name in enumerate(exported):
289
+ columns, _ = schema[name]
290
+ path = os.path.join(dump1c.DATA, name + ".parquet")
291
+ t0 = time.time()
292
+ p = plan(source, name, columns, schema, objects, path)
293
+ if not p.whole and not same_shape(path, dump1c.arrow_schema(columns)):
294
+ p.whole, p.kind = True, p.kind + " (reshaped)"
295
+ if p.whole:
296
+ rows = whole_table(name, columns)
297
+ result = {"fresh": rows, "dropped": None, "rows": rows}
298
+ else:
299
+ result = rebuild(source, p, columns, path)
300
+ held["tables"][name].update({"rows": result["rows"], "horizon": now.hex(),
301
+ "refreshed": datetime.datetime.now().replace(microsecond=0).isoformat()})
302
+ kind = p.kind.split()[0]
303
+ t = totals.setdefault(kind, [0, 0, 0, 0, 0.0])
304
+ t[0] += 1
305
+ t[1] += p.owners
306
+ t[2] += result["fresh"]
307
+ t[3] += result["dropped"] or 0
308
+ t[4] += time.time() - t0
309
+ if result["fresh"] and not p.whole or result["dropped"]:
310
+ summary[name] = dict(result, kind=p.kind, owners=p.owners, toggled=p.toggled)
311
+ if p.switched:
312
+ summary.setdefault(name, dict(result, kind=p.kind, owners=p.owners, toggled=p.toggled))
313
+ summary[name]["switched"] = p.switched
314
+ sys.stderr.write("\r %d/%d %-40s" % (at + 1, len(exported), name))
315
+ sys.stderr.write("\r" + " " * 60 + "\r")
316
+ held.setdefault("changes", []).append({
317
+ "from": mark.hex(), "to": now.hex(), "since": args.since,
318
+ "at": datetime.datetime.now().replace(microsecond=0).isoformat(),
319
+ "seconds": round(time.time() - started), "tables": summary})
320
+ print("%-10s %7s %10s %10s %10s %8s" % ("kind", "tables", "keys", "fresh", "dropped", "seconds"))
321
+ for kind in ("object", "lines", "movements", "whole"):
322
+ if kind in totals:
323
+ n, owners, fresh, dropped, seconds = totals[kind]
324
+ print("%-10s %7d %10d %10d %10s %8.0f" % (kind, n, owners, fresh,
325
+ dropped if kind != "whole" else "-", seconds))
326
+ print("%.0fs in all" % (time.time() - started))
@@ -0,0 +1,53 @@
1
+ # -*- coding: utf-8 -*-
2
+ """The platform's own CLI, run on this machine: `npx -y @dforge-core/dforge-cli@latest`.
3
+
4
+ The same command the editor extension runs, so what it installs and what it reports are
5
+ what the editor would install and report - no copy of the CLI baked into a container image
6
+ that can lag the published one, and no staging of modules into the platform checkout.
7
+
8
+ `DFORGE_CLI` overrides the command (a whitespace-separated command plus flags). A local
9
+ install opens the platform's databases itself, so it needs the auth database's connection
10
+ string; `ConnectionStrings__AuthDatabase` is taken as set, or built from the same
11
+ `DF_PG_*` the loader uses, against the platform's `dforge` database (`DF_AUTH_DB`
12
+ overrides the name).
13
+ """
14
+ from __future__ import print_function
15
+
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+
20
+ DEFAULT = "npx -y @dforge-core/dforge-cli@latest"
21
+
22
+
23
+ def command():
24
+ """The CLI as an argv prefix. On Windows `npx` is a .cmd, so it is resolved by PATH."""
25
+ parts = (os.environ.get("DFORGE_CLI") or DEFAULT).split()
26
+ found = shutil.which(parts[0])
27
+ if not found:
28
+ raise SystemExit("cli: %s is not on PATH (install Node.js, or set DFORGE_CLI)" % parts[0])
29
+ return [found] + parts[1:]
30
+
31
+
32
+ def environment():
33
+ """os.environ plus the auth database's connection string, for a local install."""
34
+ import migrate
35
+ migrate.load_env()
36
+ env = dict(os.environ)
37
+ if not env.get("ConnectionStrings__AuthDatabase"):
38
+ env["ConnectionStrings__AuthDatabase"] = (
39
+ "Host=%s;Port=%s;Database=%s;Username=%s;Password=%s" % (
40
+ env.get("DF_PG_HOST", "localhost"), env.get("DF_PG_PORT", "5432"),
41
+ env.get("DF_AUTH_DB", "dforge"), env.get("DF_PG_USER", "dforge"),
42
+ env.get("DF_PG_PASS", "")))
43
+ return env
44
+
45
+
46
+ def run(args, cwd=None):
47
+ """(exit code, combined output) of one CLI command."""
48
+ proc = subprocess.Popen(command() + list(args), cwd=cwd, env=environment(),
49
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
50
+ out = proc.communicate()[0].decode("utf-8", "replace")
51
+ # npx announces itself on every run; none of it is the CLI's answer.
52
+ out = "\n".join(l for l in out.splitlines() if not l.startswith("npm notice"))
53
+ return proc.returncode, out