pgkiln 0.75.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ // (sprint 144, AP-10; APEX 26.2: APEXlang static analysis with SQLcl CodeScan) `pgkiln advise`: the builder's
2
+ // Advisor from the command line, for CI. An export (directory, .zip or JSON) is imported into a transaction that is
3
+ // always rolled back and checked there, like `pgkiln import --dry-run`; an application already in the database is
4
+ // checked in place (also rolled back). Every SQL fragment is planned as the application's role, references are
5
+ // followed, and the security defaults are checked (src/builder/advisor.ts). The findings come out as text, JSON or
6
+ // SARIF 2.1.0 (GitHub code scanning annotates pull requests with it); each one points at the export's file of its
7
+ // page or component when there is one.
8
+ import { existsSync } from 'node:fs';
9
+ import { join, relative } from 'node:path';
10
+ import { docToFiles, slug, type FileMap } from '../appfiles.ts';
11
+ import { exportDoc, importDoc, readSource, UsageError, type Db } from './apps.ts';
12
+
13
+ export type Severity = 'error' | 'warning' | 'info';
14
+ export const SEVERITIES: Severity[] = ['error', 'warning', 'info'];
15
+
16
+ export interface AdviseFinding {
17
+ severity: Severity;
18
+ /** e.g. "page 3, region Employees", or "application" */
19
+ where: string;
20
+ field: string;
21
+ message: string;
22
+ /** the component's kind (region, item, lov …), for the SARIF rule, and its name */
23
+ kind: string | null;
24
+ label: string;
25
+ pageNo: number | null;
26
+ /** the export's file of the component (relative to the export's directory), when it has one */
27
+ file: string | null;
28
+ }
29
+
30
+ export interface AdviseResult {
31
+ alias: string;
32
+ /** where the files are, for the paths in the output: the export's directory (or null for an application) */
33
+ dir: string | null;
34
+ checked: number;
35
+ findings: AdviseFinding[];
36
+ }
37
+
38
+ const pad = (n: number) => String(n).padStart(4, '0');
39
+ const stem = (path: string) => (path.split('/').pop() ?? '').replace(/\.[a-z]+$/, '').replace(/^\d{4}-/, '');
40
+
41
+ /** The export's file of a component: its page's folder and its slug, a shared component by its slug, else the page or app file. */
42
+ export function fileOf(files: string[], f: { pageNo: number | null; kind: string | null; label: string }): string | null {
43
+ const yaml = files.filter((p) => /\.(ya?ml|json)$/.test(p));
44
+ const name = slug(f.kind === 'page' ? '' : f.label);
45
+ if (f.pageNo !== null) {
46
+ const dir = yaml.filter((p) => p.startsWith(`pages/${pad(f.pageNo!)}-`));
47
+ if (!dir.length) return null;
48
+ const own = name ? dir.find((p) => !/\/page\.(ya?ml|json)$/.test(p) && stem(p) === name) : undefined;
49
+ return own ?? dir.find((p) => /\/page\.(ya?ml|json)$/.test(p)) ?? dir[0];
50
+ }
51
+ const shared = name ? yaml.find((p) => p.startsWith('shared/') && stem(p) === name) : undefined;
52
+ return shared ?? yaml.find((p) => /^app\.(ya?ml|json)$/.test(p)) ?? null;
53
+ }
54
+
55
+ /**
56
+ * Check an export (path) or an application in the database (alias). Nothing is ever committed: an export is
57
+ * imported under a temporary alias in a transaction that is rolled back.
58
+ */
59
+ export async function adviseTarget(db: Db, target: string): Promise<AdviseResult> {
60
+ const { advise } = await import('../builder/advisor.ts');
61
+ let files: FileMap;
62
+ let dir: string | null = null;
63
+ let alias: string;
64
+ let id: number;
65
+ let checked = 0;
66
+ const findings: AdviseFinding[] = [];
67
+ const collect = async (appId: number) => {
68
+ const r = await advise(appId, db);
69
+ checked = r.checked;
70
+ for (const f of r.findings) {
71
+ const kind = f.entry?.kind ?? null;
72
+ const pageNo = f.entry?.pageNo ?? (/^Page (\d+)\b/.exec(f.field) ? Number(/^Page (\d+)\b/.exec(f.field)![1]) : null);
73
+ findings.push({
74
+ severity: f.severity,
75
+ where: f.entry ? (kind === 'page' ? f.entry.label : `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}`) : pageNo !== null ? `page ${pageNo}` : 'application',
76
+ field: f.field,
77
+ message: f.message,
78
+ kind,
79
+ label: f.entry?.label ?? '',
80
+ pageNo,
81
+ file: null,
82
+ });
83
+ }
84
+ };
85
+ if (existsSync(target)) {
86
+ const source = await readSource(target);
87
+ files = source.files ?? docToFiles(source.doc);
88
+ dir = source.files ? target : null;
89
+ alias = String(source.doc?.app?.alias ?? 'app');
90
+ // a free alias for the check: the export's own may be taken by the application it came from
91
+ const temp = `advise-${process.pid}-${Date.now().toString(36)}`.slice(0, 40);
92
+ await db.query('begin');
93
+ try {
94
+ id = (await db.query('select meta.import_app($1::jsonb, $2) as id', [JSON.stringify(source.doc), temp])).rows[0].id;
95
+ await collect(id);
96
+ } finally {
97
+ await db.query('rollback');
98
+ }
99
+ } else {
100
+ if (/[/\\]|\.(json|zip)$/i.test(target)) throw new UsageError(`${target} not found`);
101
+ alias = target;
102
+ const row = (await db.query('select id from meta.app where alias = $1', [alias])).rows[0];
103
+ if (!row) throw new UsageError(`application ${alias} not found (pgkiln apps lists them), and no file or directory ${alias} exists`);
104
+ id = row.id;
105
+ files = docToFiles(await exportDoc(db, alias), 'text');
106
+ await db.query('begin');
107
+ try {
108
+ await collect(id);
109
+ } finally {
110
+ await db.query('rollback');
111
+ }
112
+ }
113
+ const paths = [...files.keys()];
114
+ for (const f of findings) f.file = fileOf(paths, f);
115
+ return { alias, dir, checked, findings };
116
+ }
117
+
118
+ // ------------------------------------------------------------------ output
119
+
120
+ const order = (s: Severity) => SEVERITIES.indexOf(s);
121
+
122
+ /** Whether the findings fail the run: any at or above the level ("never": no). */
123
+ export function fails(findings: AdviseFinding[], failOn: Severity | 'never') {
124
+ return failOn !== 'never' && findings.some((f) => order(f.severity) <= order(failOn));
125
+ }
126
+
127
+ /** A finding's path for the output: inside the export's directory, relative to the current directory. */
128
+ const pathOf = (r: AdviseResult, f: AdviseFinding, base: string | null) =>
129
+ f.file === null ? null : base !== null ? join(base, f.file).replace(/\\/g, '/') : r.dir ? relative(process.cwd(), join(r.dir, f.file)).replace(/\\/g, '/') : f.file;
130
+
131
+ export function formatText(r: AdviseResult, base: string | null = null) {
132
+ const count = (s: Severity) => r.findings.filter((f) => f.severity === s).length;
133
+ return r.findings.map((f) => {
134
+ const path = pathOf(r, f, base);
135
+ return `${f.severity.toUpperCase().padEnd(7)} ${f.where}${f.field ? ` (${f.field})` : ''}: ${f.message}${path ? `\n ${path}` : ''}\n`;
136
+ }).join('') + `${r.alias}: ${r.checked} SQL fragment(s) checked; ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s).\n`;
137
+ }
138
+
139
+ export function formatJson(r: AdviseResult, base: string | null = null) {
140
+ return JSON.stringify({
141
+ alias: r.alias,
142
+ checked: r.checked,
143
+ counts: Object.fromEntries(SEVERITIES.map((s) => [s, r.findings.filter((f) => f.severity === s).length])),
144
+ findings: r.findings.map((f) => ({ severity: f.severity, where: f.where, field: f.field, message: f.message, page: f.pageNo, kind: f.kind, file: pathOf(r, f, base) })),
145
+ }, null, 2) + '\n';
146
+ }
147
+
148
+ /** A finding's rule: the component kind and the field (stable across runs, so code scanning can track it). */
149
+ const ruleOf = (f: AdviseFinding) => `pgkiln/${f.kind ?? (f.pageNo !== null ? 'page' : 'application')}/${slug(f.field.replace(/^Page \d+ → /, '')) || 'check'}`;
150
+
151
+ /** SARIF 2.1.0 (GitHub code scanning, other CI tools): one result per finding, at its file when it has one. */
152
+ export function formatSarif(r: AdviseResult, version: string, base: string | null = null) {
153
+ const level = { error: 'error', warning: 'warning', info: 'note' } as const;
154
+ const rules = new Map<string, { id: string; shortDescription: { text: string } }>();
155
+ const results = r.findings.map((f) => {
156
+ const ruleId = ruleOf(f);
157
+ if (!rules.has(ruleId)) rules.set(ruleId, { id: ruleId, shortDescription: { text: `${f.kind ?? 'application'}: ${f.field || 'check'}` } });
158
+ const path = pathOf(r, f, base);
159
+ return {
160
+ ruleId,
161
+ level: level[f.severity],
162
+ message: { text: `${f.where}${f.field ? ` (${f.field})` : ''}: ${f.message}` },
163
+ locations: [{
164
+ ...(path ? { physicalLocation: { artifactLocation: { uri: path }, region: { startLine: 1 } } } : {}),
165
+ logicalLocations: [{ name: f.where, kind: 'object' }],
166
+ }],
167
+ };
168
+ });
169
+ return JSON.stringify({
170
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
171
+ version: '2.1.0',
172
+ runs: [{
173
+ tool: { driver: { name: 'pgkiln advise', version, informationUri: 'https://github.com/pgkiln/pgkiln', rules: [...rules.values()] } },
174
+ results,
175
+ }],
176
+ }, null, 2) + '\n';
177
+ }
package/src/cli/apps.ts CHANGED
@@ -95,7 +95,7 @@ export async function importDoc(db: Db, doc: Doc, { alias, replace, check = 'off
95
95
  const { advise } = await import('../builder/advisor.ts');
96
96
  findings = (await advise(id, db)).findings.map((f) => ({
97
97
  severity: f.severity,
98
- where: f.entry ? `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}` : 'database',
98
+ where: f.entry ? (f.entry.kind === 'page' ? f.entry.label : `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}`) : 'database',
99
99
  field: f.field,
100
100
  message: f.message,
101
101
  }));
package/src/cli/main.ts CHANGED
@@ -84,6 +84,28 @@ async function checkPassword(password: string, username: string) {
84
84
  }
85
85
  }
86
86
 
87
+ /** (sprint 144) pgkiln lock / unlock */
88
+ async function lockCommand(v: Values, alias: string, what: 'lock' | 'unlock') {
89
+ const by = (v.as as string) ?? process.env.PGKILN_DEVELOPER ?? userInfo().username;
90
+ const page = v.page === undefined ? null : Number(v.page);
91
+ if (page !== null && !(Number.isInteger(page) && page > 0)) throw new UsageError('--page: a page number');
92
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
93
+ const wc = await import('./working-copy.ts');
94
+ const { closePools } = await import('../db.ts');
95
+ try {
96
+ out(what === 'lock' ? await wc.lockApp(alias, page, by, (v.note as string) ?? null) : await wc.unlockApp(alias, page, by, !!v.force));
97
+ return EXIT.ok;
98
+ } catch (e) {
99
+ if (e instanceof wc.WorkingCopyError) {
100
+ err(`pgkiln ${what}: ${e.message}\n`);
101
+ return EXIT.differences;
102
+ }
103
+ throw e;
104
+ } finally {
105
+ await closePools();
106
+ }
107
+ }
108
+
87
109
  // ------------------------------------------------------------------ commands
88
110
 
89
111
  const COMMANDS: Record<string, Command> = {
@@ -309,6 +331,133 @@ const COMMANDS: Record<string, Command> = {
309
331
  },
310
332
  },
311
333
 
334
+ advise: {
335
+ usage: 'pgkiln advise <path | alias> [--format text|json|sarif] [--fail-on error|warning|info|never] [-o <file>]',
336
+ summary: 'check an export or an application with the Advisor (for CI: text, JSON or SARIF)',
337
+ details:
338
+ 'Runs the builder\'s Advisor: every SQL fragment is planned as the application\'s database role (unknown\n' +
339
+ 'tables, columns, functions, types and missing grants), references to pages, items, lists of values and\n' +
340
+ 'schemes are followed, and security defaults are checked (pages that write data without sign-in or an\n' +
341
+ 'authorization scheme, unrestricted page protection, long sessions, administrators without a second factor).\n\n' +
342
+ 'A path (an export directory, a .zip or a JSON export) is imported into a transaction that is rolled back:\n' +
343
+ 'nothing changes. It needs a database with pgkiln\'s migrations and the application\'s own tables (in CI, a\n' +
344
+ 'PostgreSQL service, pgkiln migrate and your migrations). An alias checks the application in the database.\n\n' +
345
+ 'SARIF points every finding at the file of its page or component in the export, so GitHub code scanning\n' +
346
+ 'annotates the pull request. Exit code 1 when a finding is at or above --fail-on (default: error).',
347
+ options: { format: { type: 'string', short: 'f' }, 'fail-on': { type: 'string' }, output: { type: 'string', short: 'o' }, base: { type: 'string' } },
348
+ optionHelp: [
349
+ ['-f, --format <format>', 'text (default), json or sarif'],
350
+ ['--fail-on <level>', 'exit code 1 from this level: error (default), warning, info or never'],
351
+ ['-o, --output <file>', 'write the report to a file (the summary still goes to standard error)'],
352
+ ['--base <dir>', 'with an alias: the folder its export lives in, for the paths in the report (e.g. apps/hr)'],
353
+ ],
354
+ positionals: [1, 1],
355
+ async run(v, [target]) {
356
+ const format = (v.format as string) ?? 'text';
357
+ const failOn = (v['fail-on'] as string) ?? 'error';
358
+ if (!['text', 'json', 'sarif'].includes(format)) throw new UsageError(`--format: text, json or sarif, not ${format}`);
359
+ if (!['error', 'warning', 'info', 'never'].includes(failOn)) throw new UsageError(`--fail-on: error, warning, info or never, not ${failOn}`);
360
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
361
+ const { adviseTarget, fails, formatJson, formatSarif, formatText } = await import('./advise.ts');
362
+ const { closePools } = await import('../db.ts');
363
+ try {
364
+ const r = await withDb((db) => adviseTarget(db, target));
365
+ const base = (v.base as string) ?? null;
366
+ const report = format === 'sarif' ? formatSarif(r, version(), base) : format === 'json' ? formatJson(r, base) : formatText(r, base);
367
+ if (v.output) {
368
+ writeFileSync(v.output as string, report);
369
+ const count = (s: string) => r.findings.filter((f) => f.severity === s).length;
370
+ err(`${r.alias}: ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s); report in ${v.output}.\n`);
371
+ } else out(report);
372
+ return fails(r.findings, failOn as 'error') ? EXIT.differences : EXIT.ok;
373
+ } finally {
374
+ await closePools();
375
+ }
376
+ },
377
+ },
378
+
379
+ 'working-copy': {
380
+ usage: 'pgkiln working-copy <list|create|compare|merge|refresh|delete> <alias> [<name>] [--prefer copy|main] [--take <component>]… [--as <developer>]',
381
+ summary: 'make, compare, merge and delete working copies of an application',
382
+ details:
383
+ 'list <alias> the working copies of an application\n' +
384
+ 'create <alias> <name> a working copy of the application (a new application, alias printed)\n' +
385
+ 'compare <copy> the components that differ from the main application (exit 1 with conflicts)\n' +
386
+ 'merge <copy> the copy\'s changes into the main application\n' +
387
+ 'refresh <copy> the main application\'s changes into the copy\n' +
388
+ 'delete <copy> delete the working copy\n\n' +
389
+ 'Conflicts (a component changed on both sides) need a choice: --prefer copy or --prefer main for all, or\n' +
390
+ '--take <component> (as compare lists them) for each one that keeps the copy\'s version. A merge is refused\n' +
391
+ 'while another developer has locked the target application or a page it changes. Changes are logged under\n' +
392
+ '--as, else PGKILN_DEVELOPER, else the operating system user.',
393
+ options: { prefer: { type: 'string' }, take: { type: 'string', multiple: true }, as: { type: 'string' } },
394
+ optionHelp: [
395
+ ['--prefer copy|main', 'merge, refresh: resolve every conflict this way'],
396
+ ['--take <component>', 'merge, refresh: this conflict keeps the copy\'s version (repeat)'],
397
+ ['--as <developer>', 'who does it (activity log, locks)'],
398
+ ],
399
+ positionals: [2, 3],
400
+ async run(v, [action, alias, name]) {
401
+ const by = (v.as as string) ?? process.env.PGKILN_DEVELOPER ?? userInfo().username;
402
+ const prefer = v.prefer as string | undefined;
403
+ if (prefer !== undefined && prefer !== 'copy' && prefer !== 'main') throw new UsageError('--prefer: copy or main');
404
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
405
+ const wc = await import('./working-copy.ts');
406
+ const { closePools } = await import('../db.ts');
407
+ try {
408
+ switch (action) {
409
+ case 'list': out(await wc.listCopies(alias)); return EXIT.ok;
410
+ case 'create':
411
+ if (!name) throw new UsageError('create needs a name: pgkiln working-copy create <alias> <name>');
412
+ out((await wc.createWorkingCopy(alias, name, by)).text);
413
+ return EXIT.ok;
414
+ case 'compare': {
415
+ const c = await wc.compareCopy(alias);
416
+ out(c.text);
417
+ return c.conflicts ? EXIT.differences : EXIT.ok;
418
+ }
419
+ case 'merge':
420
+ case 'refresh':
421
+ out(await wc.mergeWorkingCopy(alias, { direction: action, prefer: (prefer as 'copy' | 'main') ?? null, take: (v.take as unknown as string[] | undefined) ?? [], by }));
422
+ return EXIT.ok;
423
+ case 'delete': out(await wc.deleteWorkingCopy(alias, by)); return EXIT.ok;
424
+ default: throw new UsageError(`unknown action ${action}: list, create, compare, merge, refresh or delete`);
425
+ }
426
+ } catch (e) {
427
+ if (e instanceof wc.WorkingCopyError) {
428
+ err(`pgkiln working-copy: ${e.message}\n`);
429
+ return EXIT.differences;
430
+ }
431
+ throw e;
432
+ } finally {
433
+ await closePools();
434
+ }
435
+ },
436
+ },
437
+
438
+ lock: {
439
+ usage: 'pgkiln lock <alias> [--page <n>] [--note <text>] [--as <developer>]',
440
+ summary: 'lock an application or one of its pages, as the builder\'s Lock does',
441
+ details: 'Other developers can\'t change it in the builder, import over it or merge into it until it is unlocked.',
442
+ options: { page: { type: 'string', short: 'p' }, note: { type: 'string' }, as: { type: 'string' } },
443
+ optionHelp: [['-p, --page <n>', 'lock this page only'], ['--note <text>', 'why (shown to the others)'], ['--as <developer>', 'whose lock (default PGKILN_DEVELOPER or the operating system user)']],
444
+ positionals: [1, 1],
445
+ async run(v, [alias]) {
446
+ return lockCommand(v, alias, 'lock');
447
+ },
448
+ },
449
+
450
+ unlock: {
451
+ usage: 'pgkiln unlock <alias> [--page <n>] [--force] [--as <developer>]',
452
+ summary: 'unlock an application or a page (another developer\'s lock only with --force, logged)',
453
+ options: { page: { type: 'string', short: 'p' }, force: { type: 'boolean' }, as: { type: 'string' } },
454
+ optionHelp: [['-p, --page <n>', 'unlock this page'], ['--force', 'break another developer\'s lock (logged)'], ['--as <developer>', 'who unlocks']],
455
+ positionals: [1, 1],
456
+ async run(v, [alias]) {
457
+ return lockCommand(v, alias, 'unlock');
458
+ },
459
+ },
460
+
312
461
  mcp: {
313
462
  usage: 'pgkiln mcp',
314
463
  summary: 'run an MCP server on standard input/output for AI coding agents (Claude Code, Cursor, …)',
package/src/cli/mcp.ts CHANGED
@@ -427,6 +427,99 @@ export const TOOLS: Tool[] = [
427
427
  );
428
428
  },
429
429
  },
430
+ // (sprint 144, AP-10) the Advisor and working copies, as `pgkiln advise` / `pgkiln working-copy` / `pgkiln lock`
431
+ {
432
+ name: 'advise_app',
433
+ title: 'Check an application with the Advisor',
434
+ description:
435
+ 'Runs the Advisor on an export directory, zip or JSON file (imported into a transaction that is rolled back: nothing changes) ' +
436
+ 'or on the application in the database: every SQL fragment planned as the application\'s role, references to pages, items, ' +
437
+ 'lists of values and schemes, and security defaults (pages that write data without sign-in or an authorization scheme, ' +
438
+ 'unrestricted page protection, long sessions, administrators without a second factor). Each finding names its file in the export.',
439
+ inputSchema: {
440
+ type: 'object',
441
+ properties: { ...aliasProp, path: { type: 'string', description: 'an export directory, .zip or .json instead of the application in the database' } },
442
+ },
443
+ annotations: { readOnlyHint: true },
444
+ async run(a) {
445
+ const path = str(a, 'path');
446
+ const alias = str(a, 'alias');
447
+ if (!path && !alias) throw new ToolError('give an alias or a path');
448
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
449
+ const { adviseTarget, formatText } = await import('./advise.ts');
450
+ return cap(formatText(await withDb((db) => adviseTarget(db, path ? resolve(path) : alias!))));
451
+ },
452
+ },
453
+ {
454
+ name: 'working_copy',
455
+ title: 'Working copies of an application',
456
+ description:
457
+ 'Work on a copy of an application and merge it back, as App Builder → Working copies does. action: list (the copies of alias), ' +
458
+ 'create (a copy named name; its alias is returned: export, change and import that one), compare (the components that differ from the ' +
459
+ 'main application: copy, main or CONFLICT, with ids), merge (the copy\'s changes into the main application), refresh (the main ' +
460
+ 'application\'s changes into the copy) or delete. Conflicts need prefer (copy or main) or take (the conflict ids that keep the copy\'s ' +
461
+ 'version). A merge is refused while another developer has locked the target application or a page it changes.',
462
+ inputSchema: {
463
+ type: 'object',
464
+ properties: {
465
+ action: { type: 'string', enum: ['list', 'create', 'compare', 'merge', 'refresh', 'delete'] },
466
+ alias: { type: 'string', description: 'the main application (list, create) or the working copy (compare, merge, refresh, delete)' },
467
+ name: { type: 'string', description: 'create: the copy\'s name (letters, digits, spaces, - and _)' },
468
+ prefer: { type: 'string', enum: ['copy', 'main'], description: 'merge, refresh: how to resolve every conflict' },
469
+ take: { type: 'array', items: { type: 'string' }, description: 'merge, refresh: conflict ids (from compare) that keep the copy\'s version' },
470
+ developer: { type: 'string', description: 'who does it, for the activity log and locks (default: MCP)' },
471
+ },
472
+ required: ['action', 'alias'],
473
+ },
474
+ annotations: { readOnlyHint: false, destructiveHint: true },
475
+ async run(a) {
476
+ const action = str(a, 'action', true)!;
477
+ const alias = str(a, 'alias', true)!;
478
+ const by = str(a, 'developer') ?? 'MCP';
479
+ const prefer = str(a, 'prefer');
480
+ if (prefer && prefer !== 'copy' && prefer !== 'main') throw new ToolError('prefer: copy or main');
481
+ const take = Array.isArray(a.take) ? (a.take as unknown[]).filter((x): x is string => typeof x === 'string') : [];
482
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
483
+ const wc = await import('./working-copy.ts');
484
+ switch (action) {
485
+ case 'list': return wc.listCopies(alias);
486
+ case 'create': return (await wc.createWorkingCopy(alias, str(a, 'name', true)!, by)).text;
487
+ case 'compare': return cap((await wc.compareCopy(alias)).text);
488
+ case 'merge':
489
+ case 'refresh': return wc.mergeWorkingCopy(alias, { direction: action, prefer: (prefer as 'copy' | 'main' | undefined) ?? null, take, by });
490
+ case 'delete': return wc.deleteWorkingCopy(alias, by);
491
+ default: throw new ToolError('action: list, create, compare, merge, refresh or delete');
492
+ }
493
+ },
494
+ },
495
+ {
496
+ name: 'lock_app',
497
+ title: 'Lock or unlock an application or page',
498
+ description:
499
+ 'Locks an application (or one page) so other developers can\'t change it, import over it or merge into it, as the builder\'s Lock does; ' +
500
+ 'unlock=true removes the lock (another developer\'s lock only with force=true, which is logged).',
501
+ inputSchema: {
502
+ type: 'object',
503
+ properties: {
504
+ ...aliasProp,
505
+ page: { type: 'integer', description: 'lock this page only' },
506
+ unlock: { type: 'boolean' },
507
+ force: { type: 'boolean', description: 'with unlock: break another developer\'s lock' },
508
+ note: { type: 'string', description: 'why (shown to other developers)' },
509
+ developer: { type: 'string', description: 'whose lock (default: MCP)' },
510
+ },
511
+ required: ['alias'],
512
+ },
513
+ annotations: { readOnlyHint: false },
514
+ async run(a) {
515
+ const alias = str(a, 'alias', true)!;
516
+ const page = a.page === undefined || a.page === null ? null : int(a, 'page', 1, 1_000_000);
517
+ const by = str(a, 'developer') ?? 'MCP';
518
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
519
+ const wc = await import('./working-copy.ts');
520
+ return a.unlock === true ? wc.unlockApp(alias, page, by, a.force === true) : wc.lockApp(alias, page, by, str(a, 'note') ?? null);
521
+ },
522
+ },
430
523
  {
431
524
  name: 'describe_schema',
432
525
  title: 'Describe database objects',
@@ -0,0 +1,121 @@
1
+ // (sprint 144, AP-10; APEX 26.2: the Application and Working Copy APIs for CI / CD) Working copies and builder
2
+ // locks from the command line (`pgkiln working-copy …`, `pgkiln lock` / `unlock`) and the MCP server, with the same
3
+ // rules as the builder (src/builder/workingcopies.ts, locks.ts): a copy is made, compared per component with its
4
+ // main application (three ways), merged back or refreshed; a merge is refused while another developer has locked the
5
+ // target or a page it changes; conflicts need a choice (prefer the copy's or the main version). Every change is in
6
+ // the activity log under the developer named (--as, PGKILN_DEVELOPER or the operating system user). The functions
7
+ // return text and throw WorkingCopyError for what the user must fix.
8
+ import { owner } from '../db.ts';
9
+ import { logActivity } from '../session.ts';
10
+ import { compare, copiesOf, copyOf, createCopy, deleteCopy, fingerprint, mergeCopy, WorkingCopyError, type Change } from '../workingcopy.ts';
11
+ import { lockText, type Lock } from '../builder/locks.ts';
12
+
13
+ export { WorkingCopyError };
14
+
15
+ const IP = 'cli';
16
+
17
+ async function appByAlias(alias: string) {
18
+ const a = await owner.one<{ id: number; alias: string; name: string }>('select id, alias, name from meta.app where alias = $1', [alias]);
19
+ if (!a) throw new WorkingCopyError(`Application ${alias} not found.`);
20
+ return a;
21
+ }
22
+
23
+ /** Another developer's lock on an application or one of the pages a merge changes (as the builder's merge). */
24
+ export const lockedFor = (username: string) => async (appId: number, pages: Set<number>) => {
25
+ const l = await owner.one<Lock>(
26
+ `select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock
27
+ where app_id = $1 and locked_by <> $2 and (page_no = 0 or page_no = any($3::int[])) order by page_no limit 1`,
28
+ [appId, username, [...pages]],
29
+ );
30
+ return l ? `${lockText(l)} The change is refused until it is unlocked.` : null;
31
+ };
32
+
33
+ /** The working copies of an application (alias of the main application or of a copy). */
34
+ export async function listCopies(alias: string) {
35
+ const a = await appByAlias(alias);
36
+ const wc = await copyOf(a.id);
37
+ const mainId = wc ? wc.main_app_id : a.id;
38
+ const copies = await copiesOf(mainId);
39
+ if (!copies.length) return `${alias} has no working copies.\n`;
40
+ return copies.map((c) => `${c.alias}\t${c.name}\tby ${c.created_by} ${c.created_at.slice(0, 16)}${c.merged_at ? `\tmerged ${c.merged_at.slice(0, 16)}` : ''}${c.refreshed_at ? `\trefreshed ${c.refreshed_at.slice(0, 16)}` : ''}\n`).join('');
41
+ }
42
+
43
+ export async function createWorkingCopy(alias: string, name: string, by: string) {
44
+ const a = await appByAlias(alias);
45
+ if (await copyOf(a.id)) throw new WorkingCopyError(`${alias} is itself a working copy: make the copy from its main application.`);
46
+ const id = await createCopy(a.id, name, by);
47
+ const copy = await owner.one<{ alias: string }>('select alias from meta.app where id = $1', [id]);
48
+ await logActivity({ appId: a.id, username: by, event: 'working_copy', ip: IP, detail: `created ${name} (application ${id})` });
49
+ return { id, alias: copy!.alias, text: `Working copy ${name} of ${alias}: application ${copy!.alias} (${id}).\n` };
50
+ }
51
+
52
+ const STATUS: Record<Change['status'], string> = { copy: 'copy', main: 'main', conflict: 'CONFLICT' };
53
+
54
+ /** The comparison of a working copy with its main application, one component per line. */
55
+ export async function compareCopy(alias: string) {
56
+ const a = await appByAlias(alias);
57
+ const l = await compare(a.id);
58
+ if (!l) throw new WorkingCopyError(`${alias} is not a working copy.`);
59
+ const conflicts = l.changes.filter((c) => c.status === 'conflict');
60
+ const text = l.changes.length
61
+ ? l.changes.map((c) => `${STATUS[c.status].padEnd(8)} ${c.label} [${c.id}]\n`).join('') +
62
+ `${l.changes.length} component(s) differ from ${l.mainAlias}: ${l.changes.filter((c) => c.status === 'copy').length} changed in the copy, ` +
63
+ `${l.changes.filter((c) => c.status === 'main').length} in the main application, ${conflicts.length} conflict(s).\n`
64
+ : `${alias} and ${l.mainAlias} are the same: nothing to merge.\n`;
65
+ return { changes: l.changes, conflicts: conflicts.length, mainAlias: l.mainAlias, text };
66
+ }
67
+
68
+ /**
69
+ * Merge a working copy into its main application ('merge') or bring the main application's changes into the copy
70
+ * ('refresh'). Conflicts need prefer ('copy' or 'main' for all of them) or take (component ids that keep the copy's
71
+ * version; the others keep the main's).
72
+ */
73
+ export async function mergeWorkingCopy(alias: string, o: { direction: 'merge' | 'refresh'; prefer?: 'copy' | 'main' | null; take?: string[]; by: string }) {
74
+ const a = await appByAlias(alias);
75
+ const l = await compare(a.id);
76
+ if (!l) throw new WorkingCopyError(`${alias} is not a working copy.`);
77
+ const conflicts = l.changes.filter((c) => c.status === 'conflict');
78
+ const take = new Set(o.take ?? []);
79
+ for (const id of take) if (!conflicts.some((c) => c.id === id)) throw new WorkingCopyError(`${id} is not one of the conflicts.`);
80
+ if (o.prefer === 'copy') conflicts.forEach((c) => take.add(c.id));
81
+ const open = conflicts.filter((c) => !take.has(c.id) && o.prefer !== 'main' && !(o.take?.length));
82
+ if (open.length)
83
+ throw new WorkingCopyError(`${open.length} conflict(s): choose the copy's or the main version (prefer copy|main, or take <component> for those that keep the copy's):\n` +
84
+ open.map((c) => ` ${c.label} [${c.id}]`).join('\n'));
85
+ const r = await mergeCopy(a.id, { direction: o.direction, takeCopy: take, state: fingerprint(l.changes), username: o.by, lockedBy: lockedFor(o.by) });
86
+ await logActivity({ appId: o.direction === 'merge' ? r.mainId : a.id, username: o.by, event: 'working_copy', ip: IP, detail: `${o.direction === 'merge' ? 'merged' : 'refreshed'} ${l.copy.name} (${r.changes} components)` });
87
+ return o.direction === 'merge' ? `Merged ${alias} into ${l.mainAlias} (${r.changes} component(s)).\n` : `Refreshed ${alias} from ${l.mainAlias} (${r.changes} component(s)).\n`;
88
+ }
89
+
90
+ export async function deleteWorkingCopy(alias: string, by: string) {
91
+ const a = await appByAlias(alias);
92
+ const wc = await copyOf(a.id);
93
+ if (!wc) throw new WorkingCopyError(`${alias} is not a working copy: only working copies are deleted here.`);
94
+ await deleteCopy(a.id);
95
+ await logActivity({ appId: wc.main_app_id, username: by, event: 'working_copy', ip: IP, detail: `deleted ${wc.name}` });
96
+ return `Deleted working copy ${wc.name} (${alias}).\n`;
97
+ }
98
+
99
+ /** Lock an application (page null) or one of its pages for the developer, as the builder's Lock does. */
100
+ export async function lockApp(alias: string, page: number | null, by: string, note: string | null) {
101
+ const a = await appByAlias(alias);
102
+ if (page !== null && !(await owner.one('select 1 as ok from meta.page where app_id = $1 and page_no = $2', [a.id, page]))) throw new WorkingCopyError(`${alias} has no page ${page}.`);
103
+ const r = await owner.query(`insert into meta.builder_lock (app_id, page_no, locked_by, note) values ($1, $2, $3, $4) on conflict (app_id, page_no) do nothing`,
104
+ [a.id, page ?? 0, by, note?.slice(0, 500) || null]);
105
+ if (!r.rowCount) {
106
+ const l = await owner.one<Lock>('select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 and page_no = $2', [a.id, page ?? 0]);
107
+ throw new WorkingCopyError(l ? lockText(l) : 'It is already locked.');
108
+ }
109
+ return `${page ? `Page ${page} of ${alias}` : alias} is locked by ${by}.\n`;
110
+ }
111
+
112
+ /** Unlock; another developer's lock only with force (logged as a broken lock, as an administrator does in the builder). */
113
+ export async function unlockApp(alias: string, page: number | null, by: string, force: boolean) {
114
+ const a = await appByAlias(alias);
115
+ const l = await owner.one<Lock>('select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 and page_no = $2', [a.id, page ?? 0]);
116
+ if (!l) return 'It is not locked.\n';
117
+ if (l.locked_by !== by && !force) throw new WorkingCopyError(`${lockText(l)} Only ${l.locked_by} unlocks it (or use --force).`);
118
+ await owner.query('delete from meta.builder_lock where app_id = $1 and page_no = $2', [a.id, page ?? 0]);
119
+ if (l.locked_by !== by) await logActivity({ appId: a.id, pageNo: page, username: by, event: 'lock_broken', ip: IP, detail: `command line: lock of ${l.locked_by}` });
120
+ return l.locked_by === by ? 'Unlocked.\n' : `${l.locked_by}'s lock was broken.\n`;
121
+ }
package/src/i18n/ar.ts CHANGED
@@ -475,6 +475,30 @@ export const ar: Record<MessageKey, string> = {
475
475
  'op.not_contains': "لا يحتوي على",
476
476
  'op.null': "فارغ",
477
477
  'op.not_null': "غير فارغ",
478
+ 'op.in': "أحد",
479
+ 'op.not_in': "ليس أيًا من",
480
+ 'op.between': "بين",
481
+ 'op.period': "ضمن",
482
+ 'report.value_to': "إلى",
483
+ 'report.values': "القيم",
484
+ 'report.period': "الفترة",
485
+ 'report.and': "و",
486
+ 'report.filter_help': "أحد: قيم مفصولة بفواصل أو محددة في القائمة. بين: يمكن ترك إحدى القيمتين فارغة. ضمن: فترة لعمود تاريخ.",
487
+ 'report.order_chip': "ترتيب أعمدة خاص",
488
+ 'report.order_reset': "العودة إلى ترتيب أعمدة التقرير",
489
+ 'report.order_help': "اسحب عمودًا أو استخدم الأسهم لتغيير الترتيب.",
490
+ 'report.move_up': "نقل {column} لأعلى",
491
+ 'report.move_down': "نقل {column} لأسفل",
492
+ 'report.moved': "{column}: الموضع {n} من {total}",
493
+ 'period.today': "اليوم",
494
+ 'period.yesterday': "أمس",
495
+ 'period.last_7_days': "آخر 7 أيام",
496
+ 'period.last_30_days': "آخر 30 يومًا",
497
+ 'period.next_7_days': "الأيام السبعة القادمة",
498
+ 'period.this_month': "هذا الشهر",
499
+ 'period.last_month': "الشهر الماضي",
500
+ 'period.this_year': "هذه السنة",
501
+ 'period.last_year': "السنة الماضية",
478
502
  'grid.delete_row': "حذف الصف {row}",
479
503
  'grid.new': "جديد",
480
504
  'grid.row_changed': "الصف {row}: تم تغيير هذا الصف خارج الشبكة؛ أعد تحميل الصفحة.",
package/src/i18n/cs.ts CHANGED
@@ -475,6 +475,30 @@ export const cs: Record<MessageKey, string> = {
475
475
  'op.not_contains': "neobsahuje",
476
476
  'op.null': "je prázdné",
477
477
  'op.not_null': "není prázdné",
478
+ 'op.in': "je jedno z",
479
+ 'op.not_in': "není žádné z",
480
+ 'op.between': "je mezi",
481
+ 'op.period': "je v",
482
+ 'report.value_to': "Do",
483
+ 'report.values': "Hodnoty",
484
+ 'report.period': "Období",
485
+ 'report.and': "a",
486
+ 'report.filter_help': "Je jedno z: hodnoty oddělené čárkami nebo zaškrtnuté v seznamu. Je mezi: jedna z hodnot může zůstat prázdná. Je v: období sloupce s datem.",
487
+ 'report.order_chip': "Vlastní pořadí sloupců",
488
+ 'report.order_reset': "Zpět na pořadí sloupců sestavy",
489
+ 'report.order_help': "Přetáhněte sloupec nebo použijte šipky ke změně pořadí.",
490
+ 'report.move_up': "Posunout {column} nahoru",
491
+ 'report.move_down': "Posunout {column} dolů",
492
+ 'report.moved': "{column}: pozice {n} z {total}",
493
+ 'period.today': "dnes",
494
+ 'period.yesterday': "včera",
495
+ 'period.last_7_days': "posledních 7 dnech",
496
+ 'period.last_30_days': "posledních 30 dnech",
497
+ 'period.next_7_days': "příštích 7 dnech",
498
+ 'period.this_month': "tomto měsíci",
499
+ 'period.last_month': "minulém měsíci",
500
+ 'period.this_year': "tomto roce",
501
+ 'period.last_year': "minulém roce",
478
502
  'grid.delete_row': "Smazat řádek {row}",
479
503
  'grid.new': "nový",
480
504
  'grid.row_changed': "Řádek {row}: tento řádek byl změněn mimo mřížku; načtěte stránku znovu.",