@hypequery/cli 1.18.2 → 1.18.3
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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/commands/generate-datasets.d.ts +3 -0
- package/dist/commands/generate-datasets.d.ts.map +1 -1
- package/dist/commands/generate-datasets.js +60 -2
- package/dist/generators/dataset-generator.d.ts +3 -0
- package/dist/generators/dataset-generator.d.ts.map +1 -1
- package/dist/generators/dataset-generator.js +8 -5
- package/dist/utils/generated-file-diff.d.ts +7 -0
- package/dist/utils/generated-file-diff.d.ts.map +1 -0
- package/dist/utils/generated-file-diff.js +52 -0
- package/dist/utils/generated-file.d.ts +22 -0
- package/dist/utils/generated-file.d.ts.map +1 -0
- package/dist/utils/generated-file.js +100 -0
- package/package.json +3 -3
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgCpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgCpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AAmQD,OAAO,EAAE,OAAO,EAAE,CAAC"}
|
package/dist/cli.js
CHANGED
|
@@ -143,6 +143,9 @@ program
|
|
|
143
143
|
.option('--tables <names>', 'Only generate for specific tables (comma-separated)')
|
|
144
144
|
.option('--exclude-tables <names>', 'Exclude specific tables (comma-separated)')
|
|
145
145
|
.option('--tenant-column <column>', 'Set tenantKey to this column on tables that have it (requires a trusted runtime tenant scope)')
|
|
146
|
+
.option('--force', 'Replace an existing output file when generated contents differ')
|
|
147
|
+
.option('--check', 'Exit with an error when the output file is missing or out of date')
|
|
148
|
+
.option('--diff', 'Print generated changes without writing them')
|
|
146
149
|
.action(runCommand(async (options) => {
|
|
147
150
|
await generateDatasetsCommand(options);
|
|
148
151
|
}));
|
|
@@ -10,6 +10,9 @@ export interface GenerateDatasetsOptions {
|
|
|
10
10
|
tables?: string;
|
|
11
11
|
excludeTables?: string;
|
|
12
12
|
tenantColumn?: string;
|
|
13
|
+
force?: boolean;
|
|
14
|
+
check?: boolean;
|
|
15
|
+
diff?: boolean;
|
|
13
16
|
}
|
|
14
17
|
export declare function generateDatasetsCommand(options?: GenerateDatasetsOptions): Promise<void>;
|
|
15
18
|
//# sourceMappingURL=generate-datasets.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-datasets.d.ts","sourceRoot":"","sources":["../../src/commands/generate-datasets.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"generate-datasets.d.ts","sourceRoot":"","sources":["../../src/commands/generate-datasets.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,MAAM,WAAW,uBAAuB;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AA2BD,wBAAsB,uBAAuB,CAAC,OAAO,GAAE,uBAA4B,iBAqKlF"}
|
|
@@ -11,6 +11,8 @@ import { getTableCount } from '../utils/detect-database.js';
|
|
|
11
11
|
import { generateDatasets } from '../generators/dataset-generator.js';
|
|
12
12
|
import { redactConnectionUrl } from '../utils/redact-connection-url.js';
|
|
13
13
|
import { logDatasetGenerationWarnings } from '../utils/dataset-generation-warnings.js';
|
|
14
|
+
import { formatGeneratedFileDiff } from '../utils/generated-file-diff.js';
|
|
15
|
+
import { readGeneratedFile, writeGeneratedFileAtomically } from '../utils/generated-file.js';
|
|
14
16
|
/**
|
|
15
17
|
* Turns the written path into something pasteable into an import statement:
|
|
16
18
|
* strips the `.ts` extension and makes it explicitly relative.
|
|
@@ -20,6 +22,11 @@ function toImportSpecifier(relativePath) {
|
|
|
20
22
|
const normalized = withoutExtension.split(path.sep).join('/');
|
|
21
23
|
return normalized.startsWith('.') ? normalized : `./${normalized}`;
|
|
22
24
|
}
|
|
25
|
+
function refuseOverwrite(relativeOutput) {
|
|
26
|
+
logger.warn(`Refusing to overwrite existing dataset definitions: ${relativeOutput}`);
|
|
27
|
+
logger.info('Run again with --diff to inspect changes or --force to replace the file.');
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
23
30
|
function parseTableList(value) {
|
|
24
31
|
const parsed = value
|
|
25
32
|
?.split(',')
|
|
@@ -43,6 +50,11 @@ export async function generateDatasetsCommand(options = {}) {
|
|
|
43
50
|
}
|
|
44
51
|
const parsedTables = parseTableList(options.tables);
|
|
45
52
|
const excludedTables = parseTableList(options.excludeTables);
|
|
53
|
+
if (options.force && (options.check || options.diff)) {
|
|
54
|
+
logger.error('--force cannot be combined with --check or --diff.');
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
46
58
|
logger.newline();
|
|
47
59
|
logger.header('hypequery generate datasets');
|
|
48
60
|
const spinner = ora('Connecting to ClickHouse...').start();
|
|
@@ -62,13 +74,59 @@ export async function generateDatasetsCommand(options = {}) {
|
|
|
62
74
|
// Regeneration replaces the whole file, so a tenant boundary configured at
|
|
63
75
|
// init has to be restated here or it is dropped on every refresh.
|
|
64
76
|
tenantColumn: options.tenantColumn,
|
|
77
|
+
writeOutput: false,
|
|
65
78
|
});
|
|
66
79
|
const generatedTables = generated?.tables ?? parsedTables ?? [];
|
|
67
80
|
const generatedCount = generatedTables.length || tableCount;
|
|
68
81
|
datasetSpinner.succeed(`Generated dataset definitions for ${generatedCount} ${generatedCount === 1 ? 'table' : 'tables'}`);
|
|
69
|
-
const relativeOutput = path.relative(process.cwd(), outputPath);
|
|
70
|
-
logger.success(`Created ${relativeOutput}`);
|
|
71
82
|
logDatasetGenerationWarnings(generated?.warnings);
|
|
83
|
+
const relativeOutput = path.relative(process.cwd(), outputPath);
|
|
84
|
+
const currentContents = await readGeneratedFile(outputPath);
|
|
85
|
+
const contentsMatch = currentContents === generated.contents;
|
|
86
|
+
if (contentsMatch) {
|
|
87
|
+
logger.success(options.check || options.diff
|
|
88
|
+
? `Dataset definitions are up to date: ${relativeOutput}`
|
|
89
|
+
: `Unchanged ${relativeOutput}`);
|
|
90
|
+
if (options.check || options.diff) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (options.diff) {
|
|
95
|
+
logger.raw(formatGeneratedFileDiff(currentContents ?? '', generated.contents, relativeOutput));
|
|
96
|
+
logger.error(`Generated dataset definitions differ: ${relativeOutput}`);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
else if (options.check) {
|
|
101
|
+
logger.error(currentContents === undefined
|
|
102
|
+
? `Dataset definitions are missing: ${relativeOutput}`
|
|
103
|
+
: `Generated dataset definitions are out of date: ${relativeOutput}`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
else if (currentContents !== undefined && !options.force) {
|
|
108
|
+
refuseOverwrite(relativeOutput);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
try {
|
|
113
|
+
// Without --force this is an exclusive create, so a file written
|
|
114
|
+
// between the read above and here is refused rather than clobbered.
|
|
115
|
+
await writeGeneratedFileAtomically(outputPath, generated.contents, {
|
|
116
|
+
overwrite: options.force === true,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error.code === 'EEXIST') {
|
|
121
|
+
refuseOverwrite(relativeOutput);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
logger.success(currentContents === undefined
|
|
127
|
+
? `Created ${relativeOutput}`
|
|
128
|
+
: `Updated ${relativeOutput}`);
|
|
129
|
+
}
|
|
72
130
|
logger.newline();
|
|
73
131
|
logger.header('Next steps:');
|
|
74
132
|
logger.indent('1. Review and customize the generated datasets');
|
|
@@ -19,6 +19,8 @@ export interface DatasetGeneratorOptions {
|
|
|
19
19
|
* being silently left unscoped.
|
|
20
20
|
*/
|
|
21
21
|
tenantColumn?: string;
|
|
22
|
+
/** Whether to write the output file. Defaults to true; false renders only. */
|
|
23
|
+
writeOutput?: boolean;
|
|
22
24
|
}
|
|
23
25
|
export type DatasetGenerationWarning = {
|
|
24
26
|
kind: 'tenant-key-candidate';
|
|
@@ -41,5 +43,6 @@ export declare function generateDatasets(options: DatasetGeneratorOptions): Prom
|
|
|
41
43
|
exportName: string;
|
|
42
44
|
}[];
|
|
43
45
|
warnings: DatasetGenerationWarning[];
|
|
46
|
+
contents: string;
|
|
44
47
|
}>;
|
|
45
48
|
//# sourceMappingURL=dataset-generator.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dataset-generator.d.ts","sourceRoot":"","sources":["../../src/generators/dataset-generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,qBAAqB,CAAC;AAG1E,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,MAAM,CAAC,EAAE,8BAA8B,CAAC;IACxC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,wBAAwB,GAChC;IACE,IAAI,EAAE,sBAAsB,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB,GACD;IACE,IAAI,EAAE,uBAAuB,CAAC;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAkSN;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB
|
|
1
|
+
{"version":3,"file":"dataset-generator.d.ts","sourceRoot":"","sources":["../../src/generators/dataset-generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,qBAAqB,CAAC;AAG1E,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,MAAM,CAAC,EAAE,8BAA8B,CAAC;IACxC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,MAAM,wBAAwB,GAChC;IACE,IAAI,EAAE,sBAAsB,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB,GACD;IACE,IAAI,EAAE,uBAAuB,CAAC;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAkSN;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB;;;;;;;;GAyEtE"}
|
|
@@ -277,11 +277,13 @@ ${tables.map((table) => ` ${table.name}: ${tableToPascalCase(table.name)}Datase
|
|
|
277
277
|
};
|
|
278
278
|
`;
|
|
279
279
|
const output = header + datasetDefinitions.join('\n') + footer;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
280
|
+
if (options.writeOutput !== false) {
|
|
281
|
+
// Ensure output directory exists
|
|
282
|
+
const outputDir = path.dirname(path.resolve(options.outputPath));
|
|
283
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
284
|
+
// Write the file
|
|
285
|
+
await fs.writeFile(path.resolve(options.outputPath), output);
|
|
286
|
+
}
|
|
285
287
|
// Returned so the CLI can report what it actually generated, and print an
|
|
286
288
|
// example that matches the file rather than a hardcoded placeholder.
|
|
287
289
|
return {
|
|
@@ -291,5 +293,6 @@ ${tables.map((table) => ` ${table.name}: ${tableToPascalCase(table.name)}Datase
|
|
|
291
293
|
exportName: `${tableToPascalCase(table.name)}Dataset`,
|
|
292
294
|
})),
|
|
293
295
|
warnings,
|
|
296
|
+
contents: output,
|
|
294
297
|
};
|
|
295
298
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats one unified hunk spanning the changed region, with nearby context.
|
|
3
|
+
* Generated files are replaced as a unit, so a compact single hunk is easier
|
|
4
|
+
* to review than treating every regenerated line as an unrelated edit.
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatGeneratedFileDiff(currentContents: string, generatedContents: string, displayPath: string): string;
|
|
7
|
+
//# sourceMappingURL=generated-file-diff.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generated-file-diff.d.ts","sourceRoot":"","sources":["../../src/utils/generated-file-diff.ts"],"names":[],"mappings":"AAQA;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,eAAe,EAAE,MAAM,EACvB,iBAAiB,EAAE,MAAM,EACzB,WAAW,EAAE,MAAM,GAClB,MAAM,CA+CR"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function splitLines(contents) {
|
|
2
|
+
if (contents.length === 0) {
|
|
3
|
+
return [];
|
|
4
|
+
}
|
|
5
|
+
// Keep a trailing empty line so newline-only changes remain visible.
|
|
6
|
+
return contents.split('\n');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Formats one unified hunk spanning the changed region, with nearby context.
|
|
10
|
+
* Generated files are replaced as a unit, so a compact single hunk is easier
|
|
11
|
+
* to review than treating every regenerated line as an unrelated edit.
|
|
12
|
+
*/
|
|
13
|
+
export function formatGeneratedFileDiff(currentContents, generatedContents, displayPath) {
|
|
14
|
+
if (currentContents === generatedContents) {
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
const currentLines = splitLines(currentContents);
|
|
18
|
+
const generatedLines = splitLines(generatedContents);
|
|
19
|
+
let sharedPrefix = 0;
|
|
20
|
+
while (sharedPrefix < currentLines.length
|
|
21
|
+
&& sharedPrefix < generatedLines.length
|
|
22
|
+
&& currentLines[sharedPrefix] === generatedLines[sharedPrefix]) {
|
|
23
|
+
sharedPrefix += 1;
|
|
24
|
+
}
|
|
25
|
+
let sharedSuffix = 0;
|
|
26
|
+
while (sharedSuffix < currentLines.length - sharedPrefix
|
|
27
|
+
&& sharedSuffix < generatedLines.length - sharedPrefix
|
|
28
|
+
&& currentLines[currentLines.length - sharedSuffix - 1]
|
|
29
|
+
=== generatedLines[generatedLines.length - sharedSuffix - 1]) {
|
|
30
|
+
sharedSuffix += 1;
|
|
31
|
+
}
|
|
32
|
+
const contextLines = 3;
|
|
33
|
+
const currentStart = Math.max(0, sharedPrefix - contextLines);
|
|
34
|
+
const generatedStart = Math.max(0, sharedPrefix - contextLines);
|
|
35
|
+
const currentChangedEnd = currentLines.length - sharedSuffix;
|
|
36
|
+
const generatedChangedEnd = generatedLines.length - sharedSuffix;
|
|
37
|
+
const currentEnd = Math.min(currentLines.length, currentChangedEnd + contextLines);
|
|
38
|
+
const generatedEnd = Math.min(generatedLines.length, generatedChangedEnd + contextLines);
|
|
39
|
+
const prefixContext = currentLines.slice(currentStart, sharedPrefix);
|
|
40
|
+
const suffixContext = currentLines.slice(currentChangedEnd, currentEnd);
|
|
41
|
+
const removedLines = currentLines.slice(sharedPrefix, currentChangedEnd);
|
|
42
|
+
const addedLines = generatedLines.slice(sharedPrefix, generatedChangedEnd);
|
|
43
|
+
return [
|
|
44
|
+
`--- ${displayPath}`,
|
|
45
|
+
`+++ ${displayPath} (generated)`,
|
|
46
|
+
`@@ -${currentStart + 1},${currentEnd - currentStart} +${generatedStart + 1},${generatedEnd - generatedStart} @@`,
|
|
47
|
+
...prefixContext.map((line) => ` ${line}`),
|
|
48
|
+
...removedLines.map((line) => `-${line}`),
|
|
49
|
+
...addedLines.map((line) => `+${line}`),
|
|
50
|
+
...suffixContext.map((line) => ` ${line}`),
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface WriteGeneratedFileOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Whether an existing destination may be replaced.
|
|
4
|
+
*
|
|
5
|
+
* Defaults to `false`, which makes the write an exclusive create: the
|
|
6
|
+
* existence check and the write are a single filesystem operation, so a file
|
|
7
|
+
* that appeared since the caller last looked raises
|
|
8
|
+
* {@link GeneratedFileExistsError} instead of being silently clobbered.
|
|
9
|
+
*/
|
|
10
|
+
overwrite?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Thrown when a non-overwriting write finds the destination already present.
|
|
14
|
+
*/
|
|
15
|
+
export declare class GeneratedFileExistsError extends Error {
|
|
16
|
+
readonly filePath: string;
|
|
17
|
+
readonly code = "EEXIST";
|
|
18
|
+
constructor(filePath: string);
|
|
19
|
+
}
|
|
20
|
+
export declare function readGeneratedFile(filePath: string): Promise<string | undefined>;
|
|
21
|
+
export declare function writeGeneratedFileAtomically(filePath: string, contents: string, options?: WriteGeneratedFileOptions): Promise<void>;
|
|
22
|
+
//# sourceMappingURL=generated-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generated-file.d.ts","sourceRoot":"","sources":["../../src/utils/generated-file.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,yBAAyB;IACxC;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;GAEG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;IAGrC,QAAQ,CAAC,QAAQ,EAAE,MAAM;IAFrC,QAAQ,CAAC,IAAI,YAAY;gBAEJ,QAAQ,EAAE,MAAM;CAItC;AAED,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CASrF;AAgCD,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,yBAA8B,GACtC,OAAO,CAAC,IAAI,CAAC,CA4Df"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when a non-overwriting write finds the destination already present.
|
|
6
|
+
*/
|
|
7
|
+
export class GeneratedFileExistsError extends Error {
|
|
8
|
+
filePath;
|
|
9
|
+
code = 'EEXIST';
|
|
10
|
+
constructor(filePath) {
|
|
11
|
+
super(`Refusing to overwrite existing file: ${filePath}`);
|
|
12
|
+
this.filePath = filePath;
|
|
13
|
+
this.name = 'GeneratedFileExistsError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function readGeneratedFile(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
return await readFile(filePath, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === 'ENOENT') {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Tightest of the permission bits observed on a destination, ignoring the
|
|
29
|
+
* observations where it did not exist.
|
|
30
|
+
*
|
|
31
|
+
* A `chmod` that lands between two observations must not be undone by the
|
|
32
|
+
* earlier snapshot, and the safe direction is always the more restrictive one:
|
|
33
|
+
* the replacement is never more permissive than anything the destination was
|
|
34
|
+
* seen to be.
|
|
35
|
+
*/
|
|
36
|
+
function mostRestrictiveMode(...modes) {
|
|
37
|
+
const observed = modes.filter((mode) => mode !== undefined);
|
|
38
|
+
return observed.length > 0 ? observed.reduce((tightest, mode) => tightest & mode) : undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Permission bits of an existing file, or undefined when it does not exist.
|
|
42
|
+
*/
|
|
43
|
+
async function readExistingMode(filePath) {
|
|
44
|
+
try {
|
|
45
|
+
const stats = await stat(filePath);
|
|
46
|
+
return stats.mode & 0o777;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function writeGeneratedFileAtomically(filePath, contents, options = {}) {
|
|
56
|
+
const resolvedPath = path.resolve(filePath);
|
|
57
|
+
const outputDirectory = path.dirname(resolvedPath);
|
|
58
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
59
|
+
if (!options.overwrite) {
|
|
60
|
+
try {
|
|
61
|
+
// O_EXCL: the destination is created only if it is still absent, so a
|
|
62
|
+
// concurrent create between the caller's check and this write is never
|
|
63
|
+
// lost.
|
|
64
|
+
await writeFile(resolvedPath, contents, { flag: 'wx' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === 'EEXIST') {
|
|
69
|
+
throw new GeneratedFileExistsError(resolvedPath);
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const temporaryPath = path.join(outputDirectory, `.${path.basename(resolvedPath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
75
|
+
// Read the destination's mode first: the temporary file must never be created
|
|
76
|
+
// more permissively than the file it replaces, or its contents are briefly
|
|
77
|
+
// exposed to other local users in a shared directory.
|
|
78
|
+
const existingMode = await readExistingMode(resolvedPath);
|
|
79
|
+
try {
|
|
80
|
+
await writeFile(temporaryPath, contents, existingMode !== undefined ? { mode: existingMode } : {});
|
|
81
|
+
// The destination can be chmod'ed — or created — while the contents above
|
|
82
|
+
// are being written. Renaming with only the pre-write snapshot would undo
|
|
83
|
+
// that, replacing a file just tightened to 0600 with a 0644 one, so the
|
|
84
|
+
// tighter of the two observations wins.
|
|
85
|
+
const destinationMode = mostRestrictiveMode(existingMode, await readExistingMode(resolvedPath));
|
|
86
|
+
// open() also applies the umask, and rename() swaps in a new inode, so the
|
|
87
|
+
// destination's exact mode has to be restored explicitly or a customized
|
|
88
|
+
// mode is silently reset. A chmod landing after this read still races the
|
|
89
|
+
// rename — rename() cannot be made conditional on the destination's mode —
|
|
90
|
+
// but the window is now two syscalls rather than the whole write.
|
|
91
|
+
if (destinationMode !== undefined) {
|
|
92
|
+
await chmod(temporaryPath, destinationMode);
|
|
93
|
+
}
|
|
94
|
+
await rename(temporaryPath, resolvedPath);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/cli",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.3",
|
|
4
4
|
"description": "CLI for scaffolding type-safe ClickHouse analytics, semantic datasets, React manifests, and deployments",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"clickhouse",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"open": "^10.0.0",
|
|
31
31
|
"ora": "^8.0.1",
|
|
32
32
|
"prompts": "^2.4.2",
|
|
33
|
-
"@hypequery/
|
|
34
|
-
"@hypequery/
|
|
33
|
+
"@hypequery/protocol": "0.11.1",
|
|
34
|
+
"@hypequery/deployment": "0.7.4"
|
|
35
35
|
},
|
|
36
36
|
"optionalDependencies": {
|
|
37
37
|
"@napi-rs/keyring": "1.3.0"
|