@duckcodeailabs/dql-cli 0.10.2 → 1.0.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.
@@ -2,7 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
3
3
  import { dirname, extname, join, normalize, relative, resolve } from 'node:path';
4
4
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
5
- import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, Parser, buildLineageGraph, buildManifest, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryCompleteLineagePaths, LineageGraph, } from '@duckcodeailabs/dql-core';
5
+ import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, Parser, buildLineageGraph, buildManifest, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryCompleteLineagePaths, LineageGraph, canonicalize, } from '@duckcodeailabs/dql-core';
6
6
  import { listBlockTemplates } from './block-templates.js';
7
7
  import { buildSemanticObjectDetail, buildSemanticTree, computeSyncDiff, loadSemanticImportManifest, performSemanticImport, previewSemanticImport, syncSemanticImport, } from './semantic-import.js';
8
8
  export async function startLocalServer(opts) {
@@ -219,7 +219,8 @@ export async function startLocalServer(opts) {
219
219
  return;
220
220
  }
221
221
  mkdirSync(dirname(absPath), { recursive: true });
222
- writeFileSync(absPath, content, 'utf-8');
222
+ const toWrite = absPath.endsWith('.dql') ? canonicalizeSafe(content) : content;
223
+ writeFileSync(absPath, toWrite, 'utf-8');
223
224
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
224
225
  res.end(serializeJSON({ ok: true }));
225
226
  }
@@ -229,6 +230,55 @@ export async function startLocalServer(opts) {
229
230
  }
230
231
  return;
231
232
  }
233
+ // ── run snapshots (v0.11) ───────────────────────────────────────────────
234
+ // Captures executed notebook state (query results + timings) in a
235
+ // sibling `.run.json` so notebooks can show last-run output without
236
+ // re-executing after a reload. Snapshots are git-ignored by default.
237
+ if (req.method === 'GET' && path === '/api/run-snapshot') {
238
+ const notebookPath = url.searchParams.get('path') ?? '';
239
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
240
+ res.end(serializeJSON(readRunSnapshot(projectRoot, notebookPath)));
241
+ return;
242
+ }
243
+ if (req.method === 'PUT' && path === '/api/run-snapshot') {
244
+ try {
245
+ const body = await readJSON(req);
246
+ if (!body.path || typeof body.path !== 'string' || !body.snapshot) {
247
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
248
+ res.end(serializeJSON({ error: 'Missing path or snapshot' }));
249
+ return;
250
+ }
251
+ writeRunSnapshot(projectRoot, body.path, body.snapshot);
252
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
253
+ res.end(serializeJSON({ ok: true }));
254
+ }
255
+ catch (err) {
256
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
257
+ res.end(serializeJSON({ error: err instanceof Error ? err.message : String(err) }));
258
+ }
259
+ return;
260
+ }
261
+ // ── git read-only API (v0.11) ───────────────────────────────────────────
262
+ // GET /api/git/status — branch, clean, changed files
263
+ // GET /api/git/log — last N commits (?limit=20)
264
+ // GET /api/git/diff — unified diff for a single file (?path=relative/path)
265
+ if (req.method === 'GET' && path === '/api/git/status') {
266
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
267
+ res.end(serializeJSON(await readGitStatus(projectRoot)));
268
+ return;
269
+ }
270
+ if (req.method === 'GET' && path === '/api/git/log') {
271
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? 20), 200);
272
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
273
+ res.end(serializeJSON(await readGitLog(projectRoot, limit)));
274
+ return;
275
+ }
276
+ if (req.method === 'GET' && path === '/api/git/diff') {
277
+ const filePath = url.searchParams.get('path') ?? '';
278
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
279
+ res.end(serializeJSON(await readGitDiff(projectRoot, filePath)));
280
+ return;
281
+ }
232
282
  if (req.method === 'GET' && path === '/api/schema') {
233
283
  try {
234
284
  const dataFiles = scanDataFiles(projectRoot);
@@ -2659,6 +2709,17 @@ function extractBlockStudioSemanticReferences(source) {
2659
2709
  segments: Array.from(segments),
2660
2710
  };
2661
2711
  }
2712
+ function canonicalizeSafe(source) {
2713
+ try {
2714
+ return canonicalize(source);
2715
+ }
2716
+ catch {
2717
+ // If the block body has content the parser rejects (e.g. unsupported
2718
+ // syntax in a user-provided template), keep the original bytes rather
2719
+ // than fail the write — format header gets added next time it passes fmt.
2720
+ return source;
2721
+ }
2722
+ }
2662
2723
  export function createBlockArtifacts(projectRoot, options) {
2663
2724
  const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
2664
2725
  const safeDomain = (options.domain ?? '')
@@ -2675,13 +2736,13 @@ export function createBlockArtifacts(projectRoot, options) {
2675
2736
  const templateContent = options.template
2676
2737
  ? listBlockTemplates().find((template) => template.id === options.template)?.content
2677
2738
  : undefined;
2678
- const fileContent = normalizeBlockStudioContent({
2739
+ const fileContent = canonicalizeSafe(normalizeBlockStudioContent({
2679
2740
  name: options.name,
2680
2741
  domain: safeDomain || 'uncategorized',
2681
2742
  description: options.description,
2682
2743
  tags: options.tags,
2683
2744
  content: options.content?.trim() || templateContent,
2684
- });
2745
+ }));
2685
2746
  writeFileSync(blockPath, fileContent, 'utf-8');
2686
2747
  const relativePath = safeDomain ? `blocks/${safeDomain}/${slug}.dql` : `blocks/${slug}.dql`;
2687
2748
  const companionPath = writeBlockCompanionFile(projectRoot, {
@@ -2712,9 +2773,9 @@ export function createSemanticBuilderBlock(projectRoot, options) {
2712
2773
  if (existsSync(blockPath)) {
2713
2774
  throw new Error('BLOCK_EXISTS');
2714
2775
  }
2715
- const content = options.blockType === 'custom'
2776
+ const content = canonicalizeSafe(options.blockType === 'custom'
2716
2777
  ? buildCustomSemanticBlockContent(options)
2717
- : buildSemanticBlockContent(options);
2778
+ : buildSemanticBlockContent(options));
2718
2779
  writeFileSync(blockPath, content, 'utf-8');
2719
2780
  const companionPath = writeBlockCompanionFile(projectRoot, {
2720
2781
  slug,
@@ -3083,4 +3144,124 @@ function extractVizChart(block) {
3083
3144
  }
3084
3145
  return undefined;
3085
3146
  }
3147
+ async function execGit(cwd, args) {
3148
+ const { execFile } = await import('node:child_process');
3149
+ return new Promise((resolve) => {
3150
+ execFile('git', args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
3151
+ resolve({
3152
+ stdout: String(stdout ?? ''),
3153
+ stderr: String(stderr ?? ''),
3154
+ code: err ? (err.code ? 1 : err.code ?? 1) : 0,
3155
+ });
3156
+ });
3157
+ });
3158
+ }
3159
+ async function readGitStatus(cwd) {
3160
+ const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
3161
+ if (isRepo.code !== 0 || isRepo.stdout.trim() !== 'true') {
3162
+ return { inRepo: false, branch: null, ahead: 0, behind: 0, changes: [] };
3163
+ }
3164
+ const branchRes = await execGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
3165
+ const branch = branchRes.code === 0 ? branchRes.stdout.trim() : null;
3166
+ const trackRes = await execGit(cwd, ['rev-list', '--left-right', '--count', '@{u}...HEAD']);
3167
+ let ahead = 0;
3168
+ let behind = 0;
3169
+ if (trackRes.code === 0) {
3170
+ const match = trackRes.stdout.trim().split(/\s+/);
3171
+ behind = Number(match[0] ?? 0);
3172
+ ahead = Number(match[1] ?? 0);
3173
+ }
3174
+ const statusRes = await execGit(cwd, ['status', '--porcelain=v1', '--untracked-files=normal']);
3175
+ const changes = [];
3176
+ if (statusRes.code === 0) {
3177
+ for (const line of statusRes.stdout.split('\n')) {
3178
+ if (!line)
3179
+ continue;
3180
+ const code = line.slice(0, 2);
3181
+ const p = line.slice(3);
3182
+ changes.push({ path: p, status: code });
3183
+ }
3184
+ }
3185
+ return { inRepo: true, branch, ahead, behind, changes };
3186
+ }
3187
+ async function readGitLog(cwd, limit) {
3188
+ const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
3189
+ if (isRepo.code !== 0)
3190
+ return { inRepo: false, commits: [] };
3191
+ const sep = '\x1f';
3192
+ const end = '\x1e';
3193
+ const fmt = ['%H', '%an', '%ad', '%s'].join(sep) + end;
3194
+ const res = await execGit(cwd, ['log', `-${limit}`, `--pretty=format:${fmt}`, '--date=short']);
3195
+ if (res.code !== 0)
3196
+ return { inRepo: true, commits: [] };
3197
+ const commits = [];
3198
+ for (const entry of res.stdout.split(end)) {
3199
+ const trimmed = entry.replace(/^\n/, '');
3200
+ if (!trimmed)
3201
+ continue;
3202
+ const [hash, author, date, subject] = trimmed.split(sep);
3203
+ if (hash)
3204
+ commits.push({ hash, author, date, subject });
3205
+ }
3206
+ return { inRepo: true, commits };
3207
+ }
3208
+ function snapshotPathFor(projectRoot, notebookPath) {
3209
+ const abs = safeJoin(projectRoot, notebookPath);
3210
+ if (!abs)
3211
+ return null;
3212
+ // Strip extension and append `.run.json` so `foo.dqlnb` → `foo.run.json`
3213
+ // and `bar.dql` → `bar.run.json`. Keeps the sibling file next to source.
3214
+ const dot = abs.lastIndexOf('.');
3215
+ const base = dot > abs.lastIndexOf('/') ? abs.slice(0, dot) : abs;
3216
+ return `${base}.run.json`;
3217
+ }
3218
+ function readRunSnapshot(projectRoot, notebookPath) {
3219
+ const p = snapshotPathFor(projectRoot, notebookPath);
3220
+ if (!p || !existsSync(p))
3221
+ return { found: false, snapshot: null };
3222
+ try {
3223
+ const raw = readFileSync(p, 'utf-8');
3224
+ return { found: true, snapshot: JSON.parse(raw) };
3225
+ }
3226
+ catch {
3227
+ return { found: false, snapshot: null };
3228
+ }
3229
+ }
3230
+ function writeRunSnapshot(projectRoot, notebookPath, snapshot) {
3231
+ const p = snapshotPathFor(projectRoot, notebookPath);
3232
+ if (!p)
3233
+ throw new Error('Invalid path');
3234
+ mkdirSync(dirname(p), { recursive: true });
3235
+ writeFileSync(p, JSON.stringify(snapshot, null, 2), 'utf-8');
3236
+ // Append `*.run.json` to .gitignore once, so snapshots don't pollute git
3237
+ // history unless the user deliberately un-ignores them.
3238
+ ensureGitignoreEntry(projectRoot, '*.run.json');
3239
+ }
3240
+ function ensureGitignoreEntry(projectRoot, pattern) {
3241
+ try {
3242
+ const gitignorePath = join(projectRoot, '.gitignore');
3243
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
3244
+ const lines = existing.split('\n').map((l) => l.trim());
3245
+ if (lines.includes(pattern))
3246
+ return;
3247
+ const next = existing.endsWith('\n') || existing === ''
3248
+ ? `${existing}${pattern}\n`
3249
+ : `${existing}\n${pattern}\n`;
3250
+ writeFileSync(gitignorePath, next, 'utf-8');
3251
+ }
3252
+ catch {
3253
+ // Best-effort; failure to write .gitignore shouldn't fail the snapshot.
3254
+ }
3255
+ }
3256
+ async function readGitDiff(cwd, filePath) {
3257
+ const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
3258
+ if (isRepo.code !== 0)
3259
+ return { inRepo: false, diff: '' };
3260
+ if (!filePath) {
3261
+ const res = await execGit(cwd, ['diff', '--no-color']);
3262
+ return { inRepo: true, diff: res.stdout };
3263
+ }
3264
+ const res = await execGit(cwd, ['diff', '--no-color', '--', filePath]);
3265
+ return { inRepo: true, diff: res.stdout };
3266
+ }
3086
3267
  //# sourceMappingURL=local-runtime.js.map