codify-plugin-lib 1.0.182-beta3 → 1.0.182-beta31

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 (40) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +2 -0
  3. package/dist/plugin/plugin.js +1 -1
  4. package/dist/pty/background-pty.d.ts +3 -2
  5. package/dist/pty/background-pty.js +6 -14
  6. package/dist/pty/index.d.ts +4 -2
  7. package/dist/pty/seqeuntial-pty.d.ts +3 -2
  8. package/dist/pty/seqeuntial-pty.js +44 -10
  9. package/dist/resource/parsed-resource-settings.d.ts +3 -1
  10. package/dist/resource/parsed-resource-settings.js +15 -2
  11. package/dist/resource/resource-controller.js +5 -5
  12. package/dist/resource/resource-settings.d.ts +8 -2
  13. package/dist/resource/resource-settings.js +1 -1
  14. package/dist/test.d.ts +1 -0
  15. package/dist/test.js +5 -0
  16. package/dist/utils/file-utils.d.ts +23 -0
  17. package/dist/utils/file-utils.js +186 -0
  18. package/dist/utils/functions.d.ts +12 -0
  19. package/dist/utils/functions.js +74 -0
  20. package/dist/utils/index.d.ts +4 -0
  21. package/dist/utils/index.js +30 -0
  22. package/package.json +4 -3
  23. package/src/index.ts +2 -0
  24. package/src/plugin/plugin.test.ts +31 -0
  25. package/src/plugin/plugin.ts +1 -1
  26. package/src/pty/background-pty.ts +9 -18
  27. package/src/pty/index.ts +6 -4
  28. package/src/pty/seqeuntial-pty.ts +59 -14
  29. package/src/pty/sequential-pty.test.ts +138 -5
  30. package/src/resource/parsed-resource-settings.test.ts +24 -0
  31. package/src/resource/parsed-resource-settings.ts +24 -7
  32. package/src/resource/resource-controller.test.ts +127 -1
  33. package/src/resource/resource-controller.ts +5 -6
  34. package/src/resource/resource-settings.test.ts +36 -0
  35. package/src/resource/resource-settings.ts +10 -3
  36. package/src/utils/file-utils.test.ts +7 -0
  37. package/src/utils/file-utils.ts +231 -0
  38. package/src/utils/{internal-utils.ts → functions.ts} +3 -3
  39. package/src/utils/index.ts +37 -0
  40. package/src/utils/internal-utils.test.ts +2 -1
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export * from './resource/parsed-resource-settings.js';
10
10
  export * from './resource/resource.js';
11
11
  export * from './resource/resource-settings.js';
12
12
  export * from './stateful-parameter/stateful-parameter.js';
13
+ export * from './utils/file-utils.js';
14
+ export * from './utils/functions.js';
13
15
  export * from './utils/index.js';
14
16
  export * from './utils/verbosity-level.js';
15
17
  export declare function runPlugin(plugin: Plugin): Promise<void>;
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@ export * from './resource/parsed-resource-settings.js';
10
10
  export * from './resource/resource.js';
11
11
  export * from './resource/resource-settings.js';
12
12
  export * from './stateful-parameter/stateful-parameter.js';
13
+ export * from './utils/file-utils.js';
14
+ export * from './utils/functions.js';
13
15
  export * from './utils/index.js';
14
16
  export * from './utils/verbosity-level.js';
15
17
  export async function runPlugin(plugin) {
@@ -55,7 +55,7 @@ export class Plugin {
55
55
  throw new Error(`Cannot get info for resource ${data.type}, resource doesn't exist`);
56
56
  }
57
57
  const resource = this.resourceControllers.get(data.type);
58
- const schema = resource.settings.schema;
58
+ const schema = resource.parsedSettings.schema;
59
59
  const requiredPropertyNames = (resource.settings.importAndDestroy?.requiredParameters
60
60
  ?? (typeof resource.settings.allowMultiple === 'object' ? resource.settings.allowMultiple.identifyingParameters : null)
61
61
  ?? schema?.required
@@ -6,11 +6,12 @@ import { IPty, SpawnOptions, SpawnResult } from './index.js';
6
6
  * without a tty (or even a stdin) attached so interactive commands will not work.
7
7
  */
8
8
  export declare class BackgroundPty implements IPty {
9
+ private historyIgnore;
9
10
  private basePty;
10
11
  private promiseQueue;
11
12
  constructor();
12
- spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
13
- spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
13
+ spawn(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
14
+ spawnSafe(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
14
15
  kill(): Promise<{
15
16
  exitCode: number;
16
17
  signal?: number | undefined;
@@ -17,8 +17,10 @@ EventEmitter.defaultMaxListeners = 1000;
17
17
  * without a tty (or even a stdin) attached so interactive commands will not work.
18
18
  */
19
19
  export class BackgroundPty {
20
+ historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
20
21
  basePty = pty.spawn(this.getDefaultShell(), ['-i'], {
21
- env: process.env, name: nanoid(6),
22
+ env: { ...process.env, ...this.historyIgnore },
23
+ name: nanoid(6),
22
24
  handleFlowControl: true
23
25
  });
24
26
  promiseQueue = new PromiseQueue();
@@ -28,11 +30,12 @@ export class BackgroundPty {
28
30
  async spawn(cmd, options) {
29
31
  const spawnResult = await this.spawnSafe(cmd, options);
30
32
  if (spawnResult.status !== 'success') {
31
- throw new SpawnError(cmd, spawnResult.exitCode, spawnResult.data);
33
+ throw new SpawnError(Array.isArray(cmd) ? cmd.join(' ') : cmd, spawnResult.exitCode, spawnResult.data);
32
34
  }
33
35
  return spawnResult;
34
36
  }
35
37
  async spawnSafe(cmd, options) {
38
+ cmd = Array.isArray(cmd) ? cmd.join('\\\n') : cmd;
36
39
  // cid is command id
37
40
  const cid = nanoid(10);
38
41
  debugLog(cid);
@@ -84,7 +87,7 @@ export class BackgroundPty {
84
87
  resolve(null);
85
88
  }
86
89
  });
87
- console.log(`Running command ${cmd}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
90
+ console.log(`Running command: ${cmd}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
88
91
  this.basePty.write(`${command}\r`);
89
92
  }));
90
93
  }).finally(async () => {
@@ -104,17 +107,6 @@ export class BackgroundPty {
104
107
  await this.promiseQueue.run(async () => {
105
108
  let outputBuffer = '';
106
109
  return new Promise(resolve => {
107
- // zsh-specific commands
108
- switch (Utils.getShell()) {
109
- case Shell.ZSH: {
110
- this.basePty.write('setopt HIST_NO_STORE;\n');
111
- break;
112
- }
113
- default: {
114
- this.basePty.write('export HISTIGNORE=\'history*\';\n');
115
- break;
116
- }
117
- }
118
110
  this.basePty.write(' unset PS1;\n');
119
111
  this.basePty.write(' unset PS0;\n');
120
112
  this.basePty.write(' echo setup complete\\"\n');
@@ -26,6 +26,8 @@ export interface SpawnOptions {
26
26
  cwd?: string;
27
27
  env?: Record<string, unknown>;
28
28
  interactive?: boolean;
29
+ requiresRoot?: boolean;
30
+ stdin?: boolean;
29
31
  }
30
32
  export declare class SpawnError extends Error {
31
33
  data: string;
@@ -34,8 +36,8 @@ export declare class SpawnError extends Error {
34
36
  constructor(cmd: string, exitCode: number, data: string);
35
37
  }
36
38
  export interface IPty {
37
- spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
38
- spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
39
+ spawn(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
40
+ spawnSafe(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
39
41
  kill(): Promise<{
40
42
  exitCode: number;
41
43
  signal?: number | undefined;
@@ -6,11 +6,12 @@ import { IPty, SpawnOptions, SpawnResult } from './index.js';
6
6
  * without a tty (or even a stdin) attached so interactive commands will not work.
7
7
  */
8
8
  export declare class SequentialPty implements IPty {
9
- spawn(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
10
- spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnResult>;
9
+ spawn(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
10
+ spawnSafe(cmd: string | string[], options?: SpawnOptions): Promise<SpawnResult>;
11
11
  kill(): Promise<{
12
12
  exitCode: number;
13
13
  signal?: number | undefined;
14
14
  }>;
15
+ externalSpawn(cmd: string, opts: SpawnOptions): Promise<SpawnResult>;
15
16
  private getDefaultShell;
16
17
  }
@@ -1,10 +1,17 @@
1
1
  import pty from '@homebridge/node-pty-prebuilt-multiarch';
2
+ import { Ajv } from 'ajv';
3
+ import { CommandRequestResponseDataSchema, MessageCmd } from 'codify-schemas';
4
+ import { nanoid } from 'nanoid';
2
5
  import { EventEmitter } from 'node:events';
3
6
  import stripAnsi from 'strip-ansi';
4
7
  import { Shell, Utils } from '../utils/index.js';
5
8
  import { VerbosityLevel } from '../utils/verbosity-level.js';
6
9
  import { SpawnError, SpawnStatus } from './index.js';
7
10
  EventEmitter.defaultMaxListeners = 1000;
11
+ const ajv = new Ajv({
12
+ strict: true,
13
+ });
14
+ const validateSudoRequestResponse = ajv.compile(CommandRequestResponseDataSchema);
8
15
  /**
9
16
  * The background pty is a specialized pty designed for speed. It can launch multiple tasks
10
17
  * in parallel by moving them to the background. It attaches unix FIFO pipes to each process
@@ -15,12 +22,20 @@ export class SequentialPty {
15
22
  async spawn(cmd, options) {
16
23
  const spawnResult = await this.spawnSafe(cmd, options);
17
24
  if (spawnResult.status !== 'success') {
18
- throw new SpawnError(cmd, spawnResult.exitCode, spawnResult.data);
25
+ throw new SpawnError(Array.isArray(cmd) ? cmd.join('\n') : cmd, spawnResult.exitCode, spawnResult.data);
19
26
  }
20
27
  return spawnResult;
21
28
  }
22
29
  async spawnSafe(cmd, options) {
23
- console.log(`Running command: ${cmd}` + (options?.cwd ? `(${options?.cwd})` : ''));
30
+ cmd = Array.isArray(cmd) ? cmd.join(' ') : cmd;
31
+ if (cmd.includes('sudo')) {
32
+ throw new Error('Do not directly use sudo. Use the option { requiresRoot: true } instead');
33
+ }
34
+ // If sudo is required, we must delegate to the main codify process.
35
+ if (options?.stdin || options?.requiresRoot) {
36
+ return this.externalSpawn(cmd, options);
37
+ }
38
+ console.log(`Running command: ${Array.isArray(cmd) ? cmd.join('\\\n') : cmd}` + (options?.cwd ? `(${options?.cwd})` : ''));
24
39
  return new Promise((resolve) => {
25
40
  const output = [];
26
41
  const historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
@@ -30,12 +45,13 @@ export class SequentialPty {
30
45
  ...process.env, ...options?.env,
31
46
  TERM_PROGRAM: 'codify',
32
47
  COMMAND_MODE: 'unix2003',
33
- COLORTERM: 'truecolor', ...historyIgnore
48
+ COLORTERM: 'truecolor',
49
+ ...historyIgnore
34
50
  };
35
51
  // Initial terminal dimensions
36
52
  const initialCols = process.stdout.columns ?? 80;
37
53
  const initialRows = process.stdout.rows ?? 24;
38
- const args = (options?.interactive ?? false) ? ['-i', '-c', `"${cmd}"`] : ['-c', `"${cmd}"`];
54
+ const args = options?.interactive ? ['-i', '-c', cmd] : ['-c', cmd];
39
55
  // Run the command in a pty for interactivity
40
56
  const mPty = pty.spawn(this.getDefaultShell(), args, {
41
57
  ...options,
@@ -49,20 +65,14 @@ export class SequentialPty {
49
65
  }
50
66
  output.push(data.toString());
51
67
  });
52
- const stdinListener = (data) => {
53
- mPty.write(data.toString());
54
- };
55
68
  const resizeListener = () => {
56
69
  const { columns, rows } = process.stdout;
57
70
  mPty.resize(columns, rows);
58
71
  };
59
72
  // Listen to resize events for the terminal window;
60
73
  process.stdout.on('resize', resizeListener);
61
- // Listen for user input
62
- process.stdin.on('data', stdinListener);
63
74
  mPty.onExit((result) => {
64
75
  process.stdout.off('resize', resizeListener);
65
- process.stdin.off('data', stdinListener);
66
76
  resolve({
67
77
  status: result.exitCode === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
68
78
  exitCode: result.exitCode,
@@ -78,6 +88,30 @@ export class SequentialPty {
78
88
  signal: 0,
79
89
  };
80
90
  }
91
+ // For safety reasons, requests that require sudo or are interactive must be run via the main client
92
+ async externalSpawn(cmd, opts) {
93
+ return new Promise((resolve) => {
94
+ const requestId = nanoid(8);
95
+ const listener = (data) => {
96
+ if (data.requestId === requestId) {
97
+ process.removeListener('message', listener);
98
+ if (!validateSudoRequestResponse(data.data)) {
99
+ throw new Error(`Invalid response for sudo request: ${JSON.stringify(validateSudoRequestResponse.errors, null, 2)}`);
100
+ }
101
+ resolve(data.data);
102
+ }
103
+ };
104
+ process.on('message', listener);
105
+ process.send({
106
+ cmd: MessageCmd.COMMAND_REQUEST,
107
+ data: {
108
+ command: cmd,
109
+ options: opts ?? {},
110
+ },
111
+ requestId
112
+ });
113
+ });
114
+ }
81
115
  getDefaultShell() {
82
116
  return process.env.SHELL;
83
117
  }
@@ -18,10 +18,12 @@ export type ParsedParameterSetting = {
18
18
  export declare class ParsedResourceSettings<T extends StringIndexedObject> implements ResourceSettings<T> {
19
19
  private cache;
20
20
  id: string;
21
+ description?: string;
21
22
  schema?: Partial<JSONSchemaType<T | any>>;
22
23
  allowMultiple?: {
24
+ identifyingParameters?: string[];
23
25
  matcher?: (desired: Partial<T>, current: Partial<T>) => boolean;
24
- requiredParameters?: string[];
26
+ findAllParameters?: () => Promise<Array<Partial<T>>>;
25
27
  } | boolean;
26
28
  removeStatefulParametersBeforeDestroy?: boolean | undefined;
27
29
  dependencies?: string[] | undefined;
@@ -1,8 +1,10 @@
1
+ import { ZodObject, z } from 'zod';
1
2
  import { StatefulParameterController } from '../stateful-parameter/stateful-parameter-controller.js';
2
3
  import { resolveElementEqualsFn, resolveEqualsFn, resolveMatcher, resolveParameterTransformFn } from './resource-settings.js';
3
4
  export class ParsedResourceSettings {
4
5
  cache = new Map();
5
6
  id;
7
+ description;
6
8
  schema;
7
9
  allowMultiple;
8
10
  removeStatefulParametersBeforeDestroy;
@@ -13,8 +15,19 @@ export class ParsedResourceSettings {
13
15
  settings;
14
16
  constructor(settings) {
15
17
  this.settings = settings;
16
- const { parameterSettings, ...rest } = settings;
18
+ const { parameterSettings, schema, ...rest } = settings;
17
19
  Object.assign(this, rest);
20
+ if (schema) {
21
+ this.schema = schema instanceof ZodObject
22
+ ? z.toJSONSchema(schema.strict(), {
23
+ target: 'draft-7',
24
+ override(ctx) {
25
+ ctx.jsonSchema.title = settings.id;
26
+ ctx.jsonSchema.description = settings.description ?? `${settings.id} resource. Can be used to manage ${settings.id}`;
27
+ }
28
+ })
29
+ : schema;
30
+ }
18
31
  this.validateSettings();
19
32
  }
20
33
  get typeId() {
@@ -118,7 +131,7 @@ export class ParsedResourceSettings {
118
131
  && typeof this.settings.allowMultiple === 'object' && this.settings.allowMultiple?.identifyingParameters?.includes(k))) {
119
132
  throw new Error(`Resource: ${this.id}. Stateful parameters are not allowed to be identifying parameters for allowMultiple.`);
120
133
  }
121
- const schema = this.settings.schema;
134
+ const schema = this.schema;
122
135
  if (!this.settings.importAndDestroy && (schema?.oneOf
123
136
  && Array.isArray(schema.oneOf)
124
137
  && schema.oneOf.some((s) => s.required))
@@ -17,16 +17,16 @@ export class ResourceController {
17
17
  this.settings = resource.getSettings();
18
18
  this.typeId = this.settings.id;
19
19
  this.dependencies = this.settings.dependencies ?? [];
20
- if (this.settings.schema) {
20
+ this.parsedSettings = new ParsedResourceSettings(this.settings);
21
+ if (this.parsedSettings.schema) {
21
22
  this.ajv = new Ajv({
22
23
  allErrors: true,
23
24
  strict: true,
24
25
  strictRequired: false,
25
26
  allowUnionTypes: true
26
27
  });
27
- this.schemaValidator = this.ajv.compile(this.settings.schema);
28
+ this.schemaValidator = this.ajv.compile(this.parsedSettings.schema);
28
29
  }
29
- this.parsedSettings = new ParsedResourceSettings(this.settings);
30
30
  }
31
31
  async initialize() {
32
32
  return this.resource.initialize();
@@ -374,8 +374,8 @@ ${JSON.stringify(refresh, null, 2)}
374
374
  .sort((a, b) => this.parsedSettings.statefulParameterOrder.get(a.name) - this.parsedSettings.statefulParameterOrder.get(b.name));
375
375
  }
376
376
  getAllParameterKeys() {
377
- return this.settings.schema
378
- ? Object.keys(this.settings.schema?.properties)
377
+ return this.parsedSettings.schema
378
+ ? Object.keys(this.parsedSettings.schema?.properties)
379
379
  : Object.keys(this.parsedSettings.parameterSettings);
380
380
  }
381
381
  getParametersToRefreshForImport(parameters, context) {
@@ -1,7 +1,9 @@
1
1
  import { JSONSchemaType } from 'ajv';
2
2
  import { OS, StringIndexedObject } from 'codify-schemas';
3
+ import { ZodObject } from 'zod';
3
4
  import { ArrayStatefulParameter, StatefulParameter } from '../stateful-parameter/stateful-parameter.js';
4
5
  import { RefreshContext } from './resource.js';
6
+ import { ParsedResourceSettings } from './parsed-resource-settings.js';
5
7
  export interface InputTransformation {
6
8
  to: (input: any) => Promise<any> | any;
7
9
  from: (current: any, original: any) => Promise<any> | any;
@@ -21,12 +23,16 @@ export interface ResourceSettings<T extends StringIndexedObject> {
21
23
  /**
22
24
  * Schema to validate user configs with. Must be in the format JSON Schema draft07
23
25
  */
24
- schema?: Partial<JSONSchemaType<T | any>>;
26
+ schema?: Partial<JSONSchemaType<T | any>> | ZodObject;
25
27
  /**
26
28
  * Mark the resource as sensitive. Defaults to false. This prevents the resource from automatically being imported by init and import.
27
29
  * This differs from the parameter level sensitivity which also prevents the parameter value from being displayed in the plan.
28
30
  */
29
31
  isSensitive?: boolean;
32
+ /**
33
+ * An optional description of the resource. This does not affect the behavior of the resource.
34
+ */
35
+ description?: string;
30
36
  /**
31
37
  * Allow multiple of the same resource to unique. Set truthy if
32
38
  * multiples are allowed, for example for applications, there can be multiple copy of the same application installed
@@ -290,4 +296,4 @@ export declare function resolveEqualsFn(parameter: ParameterSetting): (desired:
290
296
  export declare function resolveElementEqualsFn(parameter: ArrayParameterSetting): (desired: unknown, current: unknown) => boolean;
291
297
  export declare function resolveFnFromEqualsFnOrString(fnOrString: ((a: unknown, b: unknown) => boolean) | ParameterSettingType | undefined): ((a: unknown, b: unknown) => boolean) | undefined;
292
298
  export declare function resolveParameterTransformFn(parameter: ParameterSetting): InputTransformation | undefined;
293
- export declare function resolveMatcher<T extends StringIndexedObject>(settings: ResourceSettings<T>): (desired: Partial<T>, current: Partial<T>) => boolean;
299
+ export declare function resolveMatcher<T extends StringIndexedObject>(settings: ParsedResourceSettings<T>): (desired: Partial<T>, current: Partial<T>) => boolean;
@@ -1,6 +1,6 @@
1
1
  import isObjectsEqual from 'lodash.isequal';
2
2
  import path from 'node:path';
3
- import { addVariablesToPath, areArraysEqual, resolvePathWithVariables, tildify, untildify } from '../utils/internal-utils.js';
3
+ import { addVariablesToPath, areArraysEqual, resolvePathWithVariables, tildify, untildify } from '../utils/functions.js';
4
4
  const ParameterEqualsDefaults = {
5
5
  'boolean': (a, b) => Boolean(a) === Boolean(b),
6
6
  'directory': (a, b) => {
package/dist/test.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/test.js ADDED
@@ -0,0 +1,5 @@
1
+ import { SequentialPty } from './pty/seqeuntial-pty.js';
2
+ import { VerbosityLevel } from './utils/verbosity-level.js';
3
+ VerbosityLevel.set(1);
4
+ const $ = new SequentialPty();
5
+ await $.spawn('sudo ls', { interactive: true });
@@ -0,0 +1,23 @@
1
+ export declare class FileUtils {
2
+ static downloadFile(url: string, destination: string): Promise<void>;
3
+ static addToShellRc(line: string): Promise<void>;
4
+ static addAllToShellRc(lines: string[]): Promise<void>;
5
+ /**
6
+ * This method adds a directory path to the shell rc file if it doesn't already exist.
7
+ *
8
+ * @param value - The directory path to add.
9
+ * @param prepend - Whether to prepend the path to the existing PATH variable.
10
+ */
11
+ static addPathToShellRc(value: string, prepend: boolean): Promise<void>;
12
+ static removeFromFile(filePath: string, search: string): Promise<void>;
13
+ static removeLineFromFile(filePath: string, search: RegExp | string): Promise<void>;
14
+ static removeLineFromShellRc(search: RegExp | string): Promise<void>;
15
+ static removeAllLinesFromShellRc(searches: Array<RegExp | string>): Promise<void>;
16
+ static appendToFileWithSpacing(file: string, textToInsert: string): string;
17
+ static dirExists(path: string): Promise<boolean>;
18
+ static fileExists(path: string): Promise<boolean>;
19
+ static exists(path: string): Promise<boolean>;
20
+ static checkDirExistsOrThrowIfFile(path: string): Promise<boolean>;
21
+ static createDirIfNotExists(path: string): Promise<void>;
22
+ private static calculateEndingNewLines;
23
+ }
@@ -0,0 +1,186 @@
1
+ import * as fsSync from 'node:fs';
2
+ import * as fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { finished } from 'node:stream/promises';
6
+ import { Utils } from './index.js';
7
+ const SPACE_REGEX = /^\s*$/;
8
+ export class FileUtils {
9
+ static async downloadFile(url, destination) {
10
+ console.log(`Downloading file from ${url} to ${destination}`);
11
+ const { body } = await fetch(url);
12
+ const dirname = path.dirname(destination);
13
+ if (!await fs.stat(dirname).then((s) => s.isDirectory()).catch(() => false)) {
14
+ await fs.mkdir(dirname, { recursive: true });
15
+ }
16
+ const ws = fsSync.createWriteStream(destination);
17
+ // Different type definitions here for readable stream (NodeJS vs DOM). Small hack to fix that
18
+ await finished(Readable.fromWeb(body).pipe(ws));
19
+ console.log(`Finished downloading to ${destination}`);
20
+ }
21
+ static async addToShellRc(line) {
22
+ const lineToInsert = addLeadingSpacer(addTrailingSpacer(line));
23
+ await fs.appendFile(Utils.getPrimaryShellRc(), lineToInsert);
24
+ function addLeadingSpacer(line) {
25
+ return line.startsWith('\n')
26
+ ? line
27
+ : '\n' + line;
28
+ }
29
+ function addTrailingSpacer(line) {
30
+ return line.endsWith('\n')
31
+ ? line
32
+ : line + '\n';
33
+ }
34
+ }
35
+ static async addAllToShellRc(lines) {
36
+ const formattedLines = '\n' + lines.join('\n') + '\n';
37
+ const shellRc = Utils.getPrimaryShellRc();
38
+ console.log(`Adding to ${path.basename(shellRc)}:
39
+ ${lines.join('\n')}`);
40
+ await fs.appendFile(shellRc, formattedLines);
41
+ }
42
+ /**
43
+ * This method adds a directory path to the shell rc file if it doesn't already exist.
44
+ *
45
+ * @param value - The directory path to add.
46
+ * @param prepend - Whether to prepend the path to the existing PATH variable.
47
+ */
48
+ static async addPathToShellRc(value, prepend) {
49
+ if (await Utils.isDirectoryOnPath(value)) {
50
+ return;
51
+ }
52
+ const shellRc = Utils.getPrimaryShellRc();
53
+ console.log(`Saving path: ${value} to ${shellRc}`);
54
+ if (prepend) {
55
+ await fs.appendFile(shellRc, `\nexport PATH=$PATH:${value};`, { encoding: 'utf8' });
56
+ return;
57
+ }
58
+ await fs.appendFile(shellRc, `\nexport PATH=${value}:$PATH;`, { encoding: 'utf8' });
59
+ }
60
+ static async removeFromFile(filePath, search) {
61
+ const contents = await fs.readFile(filePath, 'utf8');
62
+ const newContents = contents.replaceAll(search, '');
63
+ await fs.writeFile(filePath, newContents, 'utf8');
64
+ }
65
+ static async removeLineFromFile(filePath, search) {
66
+ const file = await fs.readFile(filePath, 'utf8');
67
+ const lines = file.split('\n');
68
+ let searchRegex;
69
+ let searchString;
70
+ if (typeof search === 'object') {
71
+ const startRegex = /^([\t ]*)?/;
72
+ const endRegex = /([\t ]*)?/;
73
+ // Augment regex with spaces criteria to make sure this function is not deleting lines that are comments or has other content.
74
+ searchRegex = search
75
+ ? new RegExp(startRegex.source + search.source + endRegex.source, search.flags)
76
+ : search;
77
+ }
78
+ if (typeof search === 'string') {
79
+ searchString = search;
80
+ }
81
+ for (let counter = lines.length; counter >= 0; counter--) {
82
+ if (!lines[counter]) {
83
+ continue;
84
+ }
85
+ if (searchString && lines[counter].includes(searchString)) {
86
+ lines.splice(counter, 1);
87
+ continue;
88
+ }
89
+ if (searchRegex && lines[counter].search(searchRegex) !== -1) {
90
+ lines.splice(counter, 1);
91
+ }
92
+ }
93
+ await fs.writeFile(filePath, lines.join('\n'));
94
+ console.log(`Removed line: ${search} from ${filePath}`);
95
+ }
96
+ static async removeLineFromShellRc(search) {
97
+ return FileUtils.removeLineFromFile(Utils.getPrimaryShellRc(), search);
98
+ }
99
+ static async removeAllLinesFromShellRc(searches) {
100
+ for (const search of searches) {
101
+ await FileUtils.removeLineFromFile(Utils.getPrimaryShellRc(), search);
102
+ }
103
+ }
104
+ // Append the string to the end of a file ensuring at least 1 lines of space between.
105
+ // Ex result:
106
+ // something something;
107
+ //
108
+ // newline;
109
+ static appendToFileWithSpacing(file, textToInsert) {
110
+ const lines = file.trimEnd().split(/\n/);
111
+ if (lines.length === 0) {
112
+ return textToInsert;
113
+ }
114
+ const endingNewLines = FileUtils.calculateEndingNewLines(lines);
115
+ const numNewLines = endingNewLines === -1
116
+ ? 0
117
+ : Math.max(0, 2 - endingNewLines);
118
+ return lines.join('\n') + '\n'.repeat(numNewLines) + textToInsert;
119
+ }
120
+ static async dirExists(path) {
121
+ let stat;
122
+ try {
123
+ stat = await fs.stat(path);
124
+ return stat.isDirectory();
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ static async fileExists(path) {
131
+ let stat;
132
+ try {
133
+ stat = await fs.stat(path);
134
+ return stat.isFile();
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ static async exists(path) {
141
+ try {
142
+ await fs.stat(path);
143
+ return true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ static async checkDirExistsOrThrowIfFile(path) {
150
+ let stat;
151
+ try {
152
+ stat = await fs.stat(path);
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ if (stat.isDirectory()) {
158
+ return true;
159
+ }
160
+ throw new Error(`Directory ${path} already exists and is a file`);
161
+ }
162
+ static async createDirIfNotExists(path) {
163
+ if (!fsSync.existsSync(path)) {
164
+ await fs.mkdir(path, { recursive: true });
165
+ }
166
+ }
167
+ // This is overly complicated but it can be used to insert into any
168
+ // position in the future
169
+ static calculateEndingNewLines(lines) {
170
+ let counter = 0;
171
+ while (true) {
172
+ const line = lines.at(-counter - 1);
173
+ if (!line) {
174
+ return -1;
175
+ }
176
+ if (!SPACE_REGEX.test(line)) {
177
+ return counter;
178
+ }
179
+ counter++;
180
+ // Short circuit here because we don't need to check over 2;
181
+ if (counter > 2) {
182
+ return counter;
183
+ }
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,12 @@
1
+ import { ResourceConfig, StringIndexedObject } from 'codify-schemas';
2
+ export declare function splitUserConfig<T extends StringIndexedObject>(config: ResourceConfig & T): {
3
+ parameters: T;
4
+ coreParameters: ResourceConfig;
5
+ };
6
+ export declare function setsEqual(set1: Set<unknown>, set2: Set<unknown>): boolean;
7
+ export declare function untildify(pathWithTilde: string): string;
8
+ export declare function tildify(pathWithTilde: string): string;
9
+ export declare function resolvePathWithVariables(pathWithVariables: string): string;
10
+ export declare function addVariablesToPath(pathWithoutVariables: string): string;
11
+ export declare function unhome(pathWithHome: string): string;
12
+ export declare function areArraysEqual(isElementEqual: ((desired: unknown, current: unknown) => boolean) | undefined, desired: unknown, current: unknown): boolean;