@contrail/cli 3.5.0-alpha-1 → 3.6.0-alpha-2

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/README.md CHANGED
@@ -17,7 +17,7 @@ $ npm install -g @contrail/cli
17
17
  $ contrail COMMAND
18
18
  running command...
19
19
  $ contrail (--version)
20
- @contrail/cli/3.5.0-alpha-1 darwin-arm64 node-v22.19.0
20
+ @contrail/cli/3.6.0-alpha-2 darwin-arm64 node-v22.19.0
21
21
  $ contrail --help [COMMAND]
22
22
  USAGE
23
23
  $ contrail COMMAND
@@ -682,6 +682,8 @@ DESCRIPTION
682
682
 
683
683
  EXAMPLES
684
684
  $ contrail entities update color 123 -- name=obsidian hex=000000
685
+
686
+ $ contrail entities update color 123 -- description= notes='""' data=null # Unset description, empty notes, null data
685
687
  ```
686
688
 
687
689
  ## `contrail file upload FILELOCATION CONTENTTYPE`
@@ -9,6 +9,7 @@ const config_1 = require("../helpers/config/config");
9
9
  const logger_1 = tslib_1.__importDefault(require("../logger"));
10
10
  const path_1 = tslib_1.__importDefault(require("path"));
11
11
  const fs_1 = tslib_1.__importDefault(require("fs"));
12
+ const common_1 = require("../common");
12
13
  const CONFIGURATION = {
13
14
  userPoolId: 'us-east-1_qIaNZdUfm',
14
15
  clientId: process.env.CONTRAIL_COGNITO_CLIENT_ID || '7knkt34gfacbgd0ghnuspefdfg',
@@ -37,10 +38,21 @@ async function authUser() {
37
38
  };
38
39
  (0, sdk_1.setConfig)(sdk_1.Auth.config);
39
40
  try {
40
- return await refreshCurrentUserOrg();
41
+ // hacky retry policy
42
+ return await refreshCurrentUserOrg().catch(async () => {
43
+ await (0, common_1.delay)(100);
44
+ return await refreshCurrentUserOrg().catch(async () => {
45
+ await (0, common_1.delay)(1000);
46
+ return await refreshCurrentUserOrg();
47
+ });
48
+ });
41
49
  }
42
50
  catch (e) {
43
- logger_1.default.info('Token expired. Requesting auth credentials...');
51
+ if (isNetworkConnectionError(e)) {
52
+ logger_1.default.error("Encountered a network connection issue. Please ensure you're connected to the internet and try again");
53
+ process.exit(1);
54
+ }
55
+ logger_1.default.info(`Failed to refresh token: ${e}. Requesting auth credentials...`);
44
56
  }
45
57
  }
46
58
  return await authenticateWithUsernamePassword(config);
@@ -71,7 +83,8 @@ async function authenticateWithApiKey(config, apiKey, orgSlug) {
71
83
  };
72
84
  (0, sdk_1.setConfig)(sdk_1.Auth.config);
73
85
  await (0, sdk_1.getCurrentUser)();
74
- return returnAndStoreConfig(sdk_1.Auth.config);
86
+ await (0, config_1.storeConfig)(sdk_1.Auth.config);
87
+ return Object.assign({}, sdk_1.Auth.config);
75
88
  }
76
89
  async function authenticateWithUsernamePassword(config) {
77
90
  const questions = [];
@@ -159,13 +172,14 @@ async function refreshCurrentUserOrg() {
159
172
  console.log('fetched new access token from refresh token', newVibeAccessToken);
160
173
  }
161
174
  catch (e) {
162
- throw Error('Could not get refresh token');
175
+ throw Error(`Could not get refresh token: ${e}`);
163
176
  }
177
+ await (0, config_1.storeConfig)(sdk_1.Auth.config);
164
178
  }
165
179
  // TODO: To uncomment when telemetry backend is developed.
166
180
  // const current_Version = getVersionFromPackageJson();
167
181
  // await checkTelemetry(current_Version);
168
- return returnAndStoreConfig(sdk_1.Auth.config);
182
+ return Object.assign({}, sdk_1.Auth.config);
169
183
  }
170
184
  async function getVibeAccessTokenFromCognitoAccessToken(cognitoAccessToken, apiGateway) {
171
185
  const url = `${apiGateway}/auth/cognito/`;
@@ -206,8 +220,7 @@ function getVersionFromPackageJson() {
206
220
  return packageJson.version;
207
221
  }
208
222
  exports.getVersionFromPackageJson = getVersionFromPackageJson;
209
- async function returnAndStoreConfig(config) {
210
- const configCopy = Object.assign({}, config);
211
- await (0, config_1.storeConfig)(configCopy);
212
- return configCopy;
223
+ function isNetworkConnectionError(e) {
224
+ const cause = e?.cause;
225
+ return cause?.code === 'ENOTFOUND' || cause?.code === 'ECONNREFUSED';
213
226
  }
@@ -40,10 +40,12 @@ class DeployExtension extends basecommand_1.BaseCommand {
40
40
  await this.validateExtensionDeclaration(appDeclaration, extensionIdentifier);
41
41
  const extensionDir = this.getExtensionDirectory(extensionIdentifier);
42
42
  // 2. Package and upload extension
43
- const createdPackage = await (0, extension_package_deployer_1.createExtensionPackage)(app.id, extensionDir, { debug: this.flags.debug });
43
+ const createdPackage = await (0, extension_package_deployer_1.createExtensionPackage)(app.id, extensionDir, {
44
+ debug: this.flags.debug,
45
+ });
44
46
  // 3. Poll until package is ready (timeout after 1 minute)
45
47
  const readyPackage = await this.pollUntilPackageIsReady(createdPackage);
46
- logger_1.default.info("Extension package is ready...Checking for extension deployment");
48
+ logger_1.default.info('Extension package is ready...Checking for extension deployment');
47
49
  logger_1.default.debug(JSON.stringify(readyPackage, null, 2));
48
50
  const deployment = await this.pollUntilDeploymentIsReady(readyPackage.deploymentId);
49
51
  logger_1.default.info(`${chalk_1.default.green.bold('SUCCESS')} Extension package is ready.`);
@@ -288,7 +288,9 @@ describe('Entity Creation E2E Happy Path', () => {
288
288
  it('should throw error for invalid specifications', () => {
289
289
  expect(() => (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(['invalid-spec'])).toThrow('Invalid property specification: invalid-spec');
290
290
  expect(() => (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(['=value'])).toThrow('Invalid property specification: =value');
291
- expect(() => (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(['key='])).toThrow('Invalid property specification: key=');
291
+ const result = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(['key=']);
292
+ expect(result.get('key')).toBeUndefined();
293
+ expect(result.size).toBe(1);
292
294
  });
293
295
  });
294
296
  describe('isValidPropertySpecification', () => {
@@ -297,11 +299,11 @@ describe('Entity Creation E2E Happy Path', () => {
297
299
  expect((0, propertySpecifications_1.isValidPropertySpecification)('id=123')).toBe(true);
298
300
  expect((0, propertySpecifications_1.isValidPropertySpecification)('description=Long description with spaces')).toBe(true);
299
301
  expect((0, propertySpecifications_1.isValidPropertySpecification)('roles=option,family')).toBe(true);
302
+ expect((0, propertySpecifications_1.isValidPropertySpecification)('key=')).toBe(true);
300
303
  });
301
- it('should reject invalid property specifications', () => {
304
+ it('should reject most invalid property specifications', () => {
302
305
  expect((0, propertySpecifications_1.isValidPropertySpecification)('invalid')).toBe(false);
303
306
  expect((0, propertySpecifications_1.isValidPropertySpecification)('=')).toBe(false);
304
- expect((0, propertySpecifications_1.isValidPropertySpecification)('key=')).toBe(false);
305
307
  expect((0, propertySpecifications_1.isValidPropertySpecification)('=value')).toBe(false);
306
308
  expect((0, propertySpecifications_1.isValidPropertySpecification)('')).toBe(false);
307
309
  });
@@ -10,7 +10,10 @@ const propertySpecifications_1 = require("../../helpers/propertySpecifications/p
10
10
  const file_helpers_1 = require("../../helpers/file-helpers");
11
11
  class UpdateEntity extends basecommand_1.BaseCommand {
12
12
  static description = '(BETA) Update an entity';
13
- static examples = ['contrail entities update color 123 -- name=obsidian hex=000000'];
13
+ static examples = [
14
+ 'contrail entities update color 123 -- name=obsidian hex=000000',
15
+ 'contrail entities update color 123 -- description= notes=\'""\' data=null # Unset description, empty notes, null data',
16
+ ];
14
17
  static flags = {
15
18
  ...basecommand_1.BaseCommand.baseFlags,
16
19
  skipHydrateRelationships: core_1.Flags.boolean({
@@ -70,20 +73,20 @@ class UpdateEntity extends basecommand_1.BaseCommand {
70
73
  acc[key] = value;
71
74
  return acc;
72
75
  }, {});
76
+ async function updateEntity(id) {
77
+ const promise = isDryRun
78
+ ? mockUpdateById(entityName, id, changes, { skipHydrateRelationships })
79
+ : updateEntityById(entityName, id, changes, { skipHydrateRelationships });
80
+ promise
81
+ .catch(() => {
82
+ results.idsFailedToUpdate.add(id);
83
+ })
84
+ .then(() => {
85
+ results.idsSuccessfullyUpdated.add(id);
86
+ });
87
+ return promise;
88
+ }
73
89
  try {
74
- async function updateEntity(id) {
75
- const promise = isDryRun
76
- ? mockUpdateById(entityName, id, changes, { skipHydrateRelationships })
77
- : updateEntityById(entityName, id, changes, { skipHydrateRelationships });
78
- promise
79
- .catch(() => {
80
- results.idsFailedToUpdate.add(id);
81
- })
82
- .then(() => {
83
- results.idsSuccessfullyUpdated.add(id);
84
- });
85
- return promise;
86
- }
87
90
  const limit = (0, p_limit_1.default)(5); // Limit concurrency to 5
88
91
  const responses = await Promise.all(idsToUpdate.map((id) => limit(() => updateEntity(id))));
89
92
  const messagePrefix = isDryRun
@@ -115,8 +115,20 @@ async function uploadUsageInfoObjects(loadedUsageInfo) {
115
115
  entityName: 'type-property-usage-info',
116
116
  id: found.id,
117
117
  object: {
118
- editable: loadedUI.editable,
119
- used: loadedUI.used,
118
+ editable: loadedUI?.editable,
119
+ used: loadedUI?.used,
120
+ formulaFunction: loadedUI?.formulaFunction,
121
+ validationFunction: loadedUI?.validationFunction,
122
+ numberFormat: loadedUI?.numberFormat,
123
+ defaultValue: loadedUI?.defaultValue,
124
+ label: loadedUI?.label,
125
+ required: loadedUI?.required,
126
+ searchable: loadedUI?.searchable,
127
+ isUniqueProperty: loadedUI?.isUniqueProperty,
128
+ isUniquePropertyInFamily: loadedUI?.isUniquePropertyInFamily,
129
+ copyBehavior: loadedUI?.copyBehavior,
130
+ copyDefault: loadedUI?.copyDefault,
131
+ propertyLevel: loadedUI?.propertyLevel,
120
132
  },
121
133
  });
122
134
  }
@@ -107,6 +107,7 @@ class AppDeployer {
107
107
  if (appDeclaration?.accessGrants) {
108
108
  accessGrants = await app_template_parsers_1.AccessGrantsParser.buildAccessGrants(appDeclaration);
109
109
  }
110
+ // eslint-disable-next-line no-prototype-builtins
110
111
  if (appDeclaration.hasOwnProperty('dockerImageRef')) {
111
112
  throw new Error('Docker apps are no longer supported. Please use a node app.');
112
113
  }
@@ -201,7 +202,11 @@ async function createNodePackage(appIdentifier, debug, dryRun) {
201
202
  logger_1.default.info('Installing production dependencies done.');
202
203
  logger_1.default.info('Building package...');
203
204
  try {
204
- child_process_1.default.execSync('npx tsc --outDir tmp/dist');
205
+ // Check for tsconfig.build.json first, fallback to tsconfig.json
206
+ const buildConfigPath = fs_1.default.existsSync('tsconfig.build.json')
207
+ ? 'tsconfig.build.json'
208
+ : 'tsconfig.json';
209
+ child_process_1.default.execSync(`npx tsc --project ${buildConfigPath} --outDir tmp/dist`);
205
210
  }
206
211
  catch (e) {
207
212
  logger_1.default.error(`${chalk_1.default.red.bold('APP')} Failed to build package.`);
@@ -228,7 +233,7 @@ async function createNodePackage(appIdentifier, debug, dryRun) {
228
233
  return {};
229
234
  }
230
235
  logger_1.default.info('Uploading package to S3...');
231
- let apps = await entitiesClient.get({ entityName: 'app' });
236
+ const apps = await entitiesClient.get({ entityName: 'app' });
232
237
  const thisApp = apps.find((x) => x.identifier === appIdentifier);
233
238
  if (!thisApp) {
234
239
  throw new Error('App not found');
@@ -238,8 +243,7 @@ async function createNodePackage(appIdentifier, debug, dryRun) {
238
243
  object: { appId: thisApp.id },
239
244
  });
240
245
  // upload an s3 object using a signed url with an https put request
241
- const readStream = fs_1.default.readFileSync('tmp.zip');
242
- const postBody = Buffer.from(readStream);
246
+ const postBody = fs_1.default.readFileSync('tmp.zip');
243
247
  // await axios.put(uploadUrlObject.signedUrl, postBody.toString());
244
248
  await axios_1.default.request({
245
249
  method: 'PUT',
@@ -119,7 +119,7 @@ async function createExtensionPackage(appId, extensionDir, options) {
119
119
  await uploadExtensionPackage(appExtensionPackage, tmpDir, options);
120
120
  logger_1.default.info(`${chalk_1.default.green.bold('COMPLETE')} Extension packaged and uploaded.`);
121
121
  if (options.debug) {
122
- logger_1.default.debug("debug mode enabled. Not cleaning up tmp dir.");
122
+ logger_1.default.debug('debug mode enabled. Not cleaning up tmp dir.');
123
123
  }
124
124
  else {
125
125
  cleanUp(tmpDir);
@@ -41,13 +41,13 @@ exports.initializeVibeIQConfig = initializeVibeIQConfig;
41
41
  async function readVibeIQConfig() {
42
42
  let fileContent;
43
43
  try {
44
- fileContent = await (await node_fs_1.promises.readFile(configFileLocation)).toString();
44
+ fileContent = (await node_fs_1.promises.readFile(configFileLocation)).toString();
45
45
  }
46
46
  catch {
47
47
  logger_1.default.debug(`failed to open config file (${configFileLocation}) for reading.`);
48
48
  logger_1.default.debug('trying to initialize config file');
49
49
  await initializeVibeIQConfig();
50
- fileContent = await (await node_fs_1.promises.readFile(configFileLocation)).toString();
50
+ fileContent = (await node_fs_1.promises.readFile(configFileLocation)).toString();
51
51
  }
52
52
  try {
53
53
  return JSON.parse(fileContent);
@@ -92,7 +92,7 @@ async function writeConfig(config, configName = null) {
92
92
  const fileContent = await readVibeIQConfig();
93
93
  configFileName = fileContent.configInUse;
94
94
  }
95
- await node_fs_1.promises.writeFile((0, node_path_1.join)(configDirectoryLocation, `${configFileName}.json`), JSON.stringify(config, null, 2));
95
+ atomicFileWriteSync((0, node_path_1.join)(configDirectoryLocation, `${configFileName}.json`), JSON.stringify(config, null, 2));
96
96
  }
97
97
  catch (error) {
98
98
  logger_1.default.error('Could not open config file for writing.');
@@ -100,16 +100,22 @@ async function writeConfig(config, configName = null) {
100
100
  }
101
101
  }
102
102
  exports.writeConfig = writeConfig;
103
+ function atomicFileWriteSync(filePath, data) {
104
+ const randomId = Math.random().toString(36).substring(2, 15);
105
+ const tmpFilepath = `${filePath}.tmp-${randomId}`;
106
+ (0, node_fs_1.writeFileSync)(tmpFilepath, data);
107
+ (0, node_fs_1.renameSync)(tmpFilepath, filePath);
108
+ }
103
109
  async function getConfig() {
104
110
  let fileContent;
105
111
  try {
106
- fileContent = await (await node_fs_1.promises.readFile(configFileLocation)).toString();
112
+ fileContent = (await node_fs_1.promises.readFile(configFileLocation)).toString();
107
113
  }
108
114
  catch {
109
115
  logger_1.default.debug(`failed to open config file (${configFileLocation}) for reading.`);
110
116
  logger_1.default.debug('trying to initialize config file');
111
117
  await initializeVibeIQConfig();
112
- fileContent = await (await node_fs_1.promises.readFile(configFileLocation)).toString();
118
+ fileContent = (await node_fs_1.promises.readFile(configFileLocation)).toString();
113
119
  }
114
120
  try {
115
121
  const configFileJSON = JSON.parse(fileContent);
@@ -156,11 +162,11 @@ async function getConfigContent(fileName) {
156
162
  const configFilePath = (0, node_path_1.join)(configDirectoryLocation, `${fileName}.json`);
157
163
  let fileContent;
158
164
  try {
159
- fileContent = await (await node_fs_1.promises.readFile(configFilePath)).toString();
165
+ fileContent = (await node_fs_1.promises.readFile(configFilePath)).toString();
160
166
  return JSON.parse(fileContent);
161
167
  }
162
168
  catch (error) {
163
- logger_1.default.error(`Failed to open/parse config file (${fileName}) might be malformed or not found.`);
169
+ logger_1.default.error(`Failed to open/parse config file (${fileName}) might be malformed or not found. Path: ${configFilePath}, error: ${error}`);
164
170
  }
165
171
  }
166
172
  exports.getConfigContent = getConfigContent;
@@ -10,17 +10,24 @@
10
10
  *
11
11
  * It is typically used as the argv in a command.
12
12
  *
13
+ * Special handling for empty values and null:
14
+ * - slug= (empty value) → property is set to undefined (unset)
15
+ * - slug='""' or slug="''" → property is set to empty string ""
16
+ * - slug=null → property is set to null
17
+ * - slug="null" → property is set to the string "null"
18
+ *
13
19
  * Examples:
14
20
  *
15
21
  * contrail entities get color -- id=123
16
22
  * contrail entities update color 123 -- name=obsidian hex=000000
23
+ * contrail entities update color 123 -- description= notes='""' data=null
17
24
  * contrail entities get loader-processes -- createdOn=2025-09-15..2025-09-16
18
25
  */
19
26
  export type PropertySpecification = {
20
27
  slug: string;
21
- value: string;
28
+ value: string | undefined | null;
22
29
  };
23
- export type PropertySpecificationBySlug = Map<string, string | number | string[] | number[]>;
30
+ export type PropertySpecificationBySlug = Map<string, string | number | string[] | number[] | undefined | null>;
24
31
  export type PropertySpecificationString = `${string}=${string}`;
25
32
  export declare function parsePropertySpecification(spec: PropertySpecificationString): PropertySpecification;
26
33
  export declare function parsePropertySpecificationsIntoMap(propertySpecifications: PropertySpecificationString[]): PropertySpecificationBySlug;
@@ -11,15 +11,25 @@
11
11
  *
12
12
  * It is typically used as the argv in a command.
13
13
  *
14
+ * Special handling for empty values and null:
15
+ * - slug= (empty value) → property is set to undefined (unset)
16
+ * - slug='""' or slug="''" → property is set to empty string ""
17
+ * - slug=null → property is set to null
18
+ * - slug="null" → property is set to the string "null"
19
+ *
14
20
  * Examples:
15
21
  *
16
22
  * contrail entities get color -- id=123
17
23
  * contrail entities update color 123 -- name=obsidian hex=000000
24
+ * contrail entities update color 123 -- description= notes='""' data=null
18
25
  * contrail entities get loader-processes -- createdOn=2025-09-15..2025-09-16
19
26
  */
20
27
  Object.defineProperty(exports, "__esModule", { value: true });
21
28
  exports.isValidPropertySpecification = exports.parsePropertySpecificationsIntoMap = exports.parsePropertySpecification = void 0;
22
29
  const dateRanges_1 = require("./dateRanges");
30
+ // Special tokens for empty strings
31
+ const DOUBLE_QUOTED_EMPTY = '""';
32
+ const SINGLE_QUOTED_EMPTY = "''"; // eslint-disable-line quotes
23
33
  function parsePropertySpecification(spec) {
24
34
  const equalsIndex = spec.indexOf('=');
25
35
  if (equalsIndex === -1) {
@@ -27,10 +37,28 @@ function parsePropertySpecification(spec) {
27
37
  }
28
38
  const slug = spec.substring(0, equalsIndex);
29
39
  const value = spec.substring(equalsIndex + 1);
30
- if (!slug || !value) {
40
+ if (!slug) {
31
41
  throw new Error(`Invalid property specification: ${spec}`);
32
42
  }
33
- return { slug, value };
43
+ // Handle special cases for empty strings and null:
44
+ // - x= means undefined (unset the property)
45
+ // - x='""' or x="''" means empty string ""
46
+ // - x=null means null value
47
+ // - x="null" means the string "null"
48
+ let finalValue;
49
+ if (value === '') {
50
+ finalValue = undefined;
51
+ }
52
+ else if (value === DOUBLE_QUOTED_EMPTY || value === SINGLE_QUOTED_EMPTY) {
53
+ finalValue = '';
54
+ }
55
+ else if (value === 'null') {
56
+ finalValue = null;
57
+ }
58
+ else {
59
+ finalValue = value;
60
+ }
61
+ return { slug, value: finalValue };
34
62
  }
35
63
  exports.parsePropertySpecification = parsePropertySpecification;
36
64
  function parsePropertySpecificationsIntoMap(propertySpecifications) {
@@ -43,8 +71,8 @@ function parsePropertySpecificationsIntoMap(propertySpecifications) {
43
71
  continue;
44
72
  }
45
73
  const { slug, value } = parsePropertySpecification(spec);
46
- if (slug === 'roles') {
47
- // Special handling for roles, which can be an array
74
+ if (slug === 'roles' && value !== undefined) {
75
+ // Special handling for roles, which can be an array (only if value is not undefined)
48
76
  const rolesArray = value.split(',').map((role) => role.trim());
49
77
  propertySpecBySlug.set(slug, rolesArray);
50
78
  continue;
@@ -60,8 +88,8 @@ function isValidPropertySpecification(maybeSpec) {
60
88
  (0, dateRanges_1.parseDateRangeSpecification)(maybeSpec);
61
89
  return true;
62
90
  }
63
- const { slug, value } = parsePropertySpecification(maybeSpec);
64
- return !!slug && !!value;
91
+ const { slug } = parsePropertySpecification(maybeSpec);
92
+ return !!slug;
65
93
  }
66
94
  catch (e) {
67
95
  return false;
@@ -39,13 +39,66 @@ describe('parsePropertySpecification', () => {
39
39
  const result = (0, propertySpecifications_1.parsePropertySpecification)(spec);
40
40
  expect(result).toEqual({
41
41
  slug: 'formula',
42
- value: 'x=y+z', // Now correctly keeps everything after first equals
42
+ value: 'x=y+z',
43
43
  });
44
44
  });
45
45
  it('should parse property specification with empty value', () => {
46
46
  const spec = 'description=';
47
- // Current implementation throws error for empty values
48
- expect(() => (0, propertySpecifications_1.parsePropertySpecification)(spec)).toThrow('Invalid property specification: description=');
47
+ const result = (0, propertySpecifications_1.parsePropertySpecification)(spec);
48
+ expect(result).toEqual({
49
+ slug: 'description',
50
+ value: undefined,
51
+ });
52
+ });
53
+ it('should distinguish between empty value and empty string', () => {
54
+ const emptySpec = 'description=';
55
+ const emptyStringSpec = 'description=""';
56
+ const emptyResult = (0, propertySpecifications_1.parsePropertySpecification)(emptySpec);
57
+ const emptyStringResult = (0, propertySpecifications_1.parsePropertySpecification)(emptyStringSpec);
58
+ expect(emptyResult).toEqual({
59
+ slug: 'description',
60
+ value: undefined,
61
+ });
62
+ expect(emptyStringResult).toEqual({
63
+ slug: 'description',
64
+ value: '',
65
+ });
66
+ });
67
+ it('should handle both double and single quoted empty strings', () => {
68
+ const doubleQuotedSpec = 'field=""';
69
+ const singleQuotedSpec = "field=''"; // eslint-disable-line quotes
70
+ const doubleQuotedResult = (0, propertySpecifications_1.parsePropertySpecification)(doubleQuotedSpec);
71
+ const singleQuotedResult = (0, propertySpecifications_1.parsePropertySpecification)(singleQuotedSpec);
72
+ expect(doubleQuotedResult).toEqual({
73
+ slug: 'field',
74
+ value: '',
75
+ });
76
+ expect(singleQuotedResult).toEqual({
77
+ slug: 'field',
78
+ value: '',
79
+ });
80
+ });
81
+ it('should parse property specification with null value', () => {
82
+ const spec = 'description=null';
83
+ const result = (0, propertySpecifications_1.parsePropertySpecification)(spec);
84
+ expect(result).toEqual({
85
+ slug: 'description',
86
+ value: null,
87
+ });
88
+ });
89
+ it('should distinguish between null and the string "null"', () => {
90
+ const nullSpec = 'field=null';
91
+ const stringNullSpec = 'field="null"';
92
+ const nullResult = (0, propertySpecifications_1.parsePropertySpecification)(nullSpec);
93
+ const stringNullResult = (0, propertySpecifications_1.parsePropertySpecification)(stringNullSpec);
94
+ expect(nullResult).toEqual({
95
+ slug: 'field',
96
+ value: null,
97
+ });
98
+ expect(stringNullResult).toEqual({
99
+ slug: 'field',
100
+ value: '"null"',
101
+ });
49
102
  });
50
103
  it('should throw error for spec without equals sign', () => {
51
104
  const spec = 'invalidspec';
@@ -118,9 +171,26 @@ describe('parsePropertySpecificationsIntoMap', () => {
118
171
  expect(result.size).toBe(1);
119
172
  });
120
173
  it('should handle property specifications with empty values', () => {
121
- const specs = ['name=john', 'description=', 'notes='];
122
- // Current implementation throws error for empty values
123
- expect(() => (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(specs)).toThrow('Invalid property specification: description=');
174
+ const specs = ['name=john', 'description=', 'notes=""'];
175
+ const result = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(specs);
176
+ expect(result.get('name')).toBe('john');
177
+ expect(result.get('description')).toBeUndefined();
178
+ expect(result.get('notes')).toBe('');
179
+ expect(result.size).toBe(3);
180
+ });
181
+ it('should handle property specifications with null values', () => {
182
+ const specs = [
183
+ 'name=john',
184
+ 'description=null',
185
+ 'notes="null"',
186
+ 'empty=',
187
+ ];
188
+ const result = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(specs);
189
+ expect(result.get('name')).toBe('john');
190
+ expect(result.get('description')).toBeNull();
191
+ expect(result.get('notes')).toBe('"null"');
192
+ expect(result.get('empty')).toBeUndefined();
193
+ expect(result.size).toBe(4);
124
194
  });
125
195
  it('should throw error for invalid property specifications', () => {
126
196
  const specs = [
@@ -141,9 +211,9 @@ describe('isValidPropertySpecification', () => {
141
211
  expect((0, propertySpecifications_1.isValidPropertySpecification)('property-name=value')).toBe(true);
142
212
  });
143
213
  it('should return true for property specifications with empty values', () => {
144
- // Current implementation rejects empty values
145
- expect((0, propertySpecifications_1.isValidPropertySpecification)('description=')).toBe(false);
146
- expect((0, propertySpecifications_1.isValidPropertySpecification)('notes=')).toBe(false);
214
+ // Now implementation should accept empty values
215
+ expect((0, propertySpecifications_1.isValidPropertySpecification)('description=')).toBe(true);
216
+ expect((0, propertySpecifications_1.isValidPropertySpecification)('notes=')).toBe(true);
147
217
  });
148
218
  it('should return false for invalid property specifications', () => {
149
219
  expect((0, propertySpecifications_1.isValidPropertySpecification)('invalid')).toBe(false);
@@ -195,6 +265,35 @@ describe('integration tests', () => {
195
265
  expect(map.get('id')).toBe('123');
196
266
  expect(map.size).toBe(2);
197
267
  });
268
+ it('should handle command line parsing issue with empty strings', () => {
269
+ // When user types: contrail entities update color 123 -- description="" notes=''
270
+ // The shell may convert this to: ['description=', 'notes='] or ['description=""', 'notes=\'\'']
271
+ // depending on the shell and escaping
272
+ // Test case 1: Shell strips quotes (common case)
273
+ const shellStrippedSpecs = ['description=', 'notes='];
274
+ const shellStrippedMap = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(shellStrippedSpecs);
275
+ expect(shellStrippedMap.get('description')).toBeUndefined(); // Empty = undefined
276
+ expect(shellStrippedMap.get('notes')).toBeUndefined(); // Empty = undefined
277
+ // Test case 2: Shell preserves quotes (desired case for empty strings)
278
+ const quotedSpecs = ['description=""'];
279
+ const quotedMap = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(quotedSpecs);
280
+ expect(quotedMap.get('description')).toBe(''); // Quoted empty = empty string
281
+ // Test case 3: Mixed usage with null
282
+ const mixedSpecs = [
283
+ 'name=test',
284
+ 'description=',
285
+ 'notes=""',
286
+ 'data=null',
287
+ 'status="null"',
288
+ ];
289
+ const mixedMap = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(mixedSpecs);
290
+ expect(mixedMap.get('name')).toBe('test');
291
+ expect(mixedMap.get('description')).toBeUndefined(); // Unset
292
+ expect(mixedMap.get('notes')).toBe(''); // Empty string
293
+ expect(mixedMap.get('data')).toBeNull(); // null value
294
+ expect(mixedMap.get('status')).toBe('"null"'); // String "null"
295
+ expect(mixedMap.size).toBe(5);
296
+ });
198
297
  });
199
298
  describe('edge cases and limitations', () => {
200
299
  it('should handle multiple equals signs correctly', () => {
@@ -263,4 +362,20 @@ describe('edge cases and limitations', () => {
263
362
  value: 'value',
264
363
  });
265
364
  });
365
+ it('should document all special value types', () => {
366
+ // This test documents all the special value types and their parsing behavior
367
+ const specs = [
368
+ { input: 'a=value', expected: 'value', description: 'regular string' },
369
+ { input: 'b=', expected: undefined, description: 'undefined (unset)' },
370
+ { input: 'c=""', expected: '', description: 'empty string' },
371
+ { input: 'd=null', expected: null, description: 'null value' },
372
+ { input: 'e="null"', expected: '"null"', description: 'string "null"' },
373
+ { input: 'f=123', expected: '123', description: 'numeric string' },
374
+ { input: 'g=true', expected: 'true', description: 'boolean string' },
375
+ ];
376
+ for (const { input, expected } of specs) {
377
+ const result = (0, propertySpecifications_1.parsePropertySpecification)(input);
378
+ expect(result.value).toBe(expected);
379
+ }
380
+ });
266
381
  });
@@ -188,6 +188,7 @@ async function uploadReducedTypeProperties(type, currentPropertyTypes, newProper
188
188
  validationFunction: newPropertyType.validationFunction,
189
189
  numberFormat: newPropertyType.numberFormat,
190
190
  sequenceSeedValue: newPropertyType.sequenceSeedValue,
191
+ copyBehavior: newPropertyType?.copyBehavior,
191
192
  }),
192
193
  headers: {
193
194
  Accept: 'application/json',
@@ -199,6 +200,11 @@ async function uploadReducedTypeProperties(type, currentPropertyTypes, newProper
199
200
  // !newPropertyType.inherited &&
200
201
  // !typePropertiesEqual(found, newPropertyType, optionSetIdBySlug)
201
202
  ) {
203
+ // Skip platform-managed entities
204
+ if (newPropertyType.core) {
205
+ logger_1.default.debug(`Skipping platform-managed property: ${newPropertyType.slug}`);
206
+ continue;
207
+ }
202
208
  const proposedTP = {
203
209
  isArchived: newPropertyType.isArchived ?? null,
204
210
  propertyType: newPropertyType.propertyType ?? null,
@@ -223,6 +229,7 @@ async function uploadReducedTypeProperties(type, currentPropertyTypes, newProper
223
229
  validationFunction: newPropertyType.validationFunction ?? null,
224
230
  numberFormat: newPropertyType.numberFormat ?? null,
225
231
  sequenceSeedValue: newPropertyType.sequenceSeedValue ?? null,
232
+ copyBehavior: newPropertyType.copyBehavior ?? null,
226
233
  };
227
234
  // logger.debug(
228
235
  // `found type property option set: ${newPropertyType.typePropertyOptionSetSlug}, ${proposedTP.typePropertyOptionSetId}, `,
@@ -360,6 +360,7 @@ export interface ReducedTypeProperty {
360
360
  validationFunction?: string;
361
361
  numberFormat?: any;
362
362
  sequenceSeedValue?: number;
363
+ copyBehavior?: string;
363
364
  }
364
365
  export interface ReducedOptionSet {
365
366
  name: string;
@@ -1,5 +1,7 @@
1
1
  ### Running your app
2
+
2
3
  Run `npm run start` at the root directory of your app (where the `package.json` file is located).
3
4
 
4
5
  ### Publishing your app
5
- Run `contrail app publish` to publish a new version of the app.
6
+
7
+ Run `contrail app publish` to publish a new version of the app.
@@ -1,3 +1,3 @@
1
1
  {
2
2
  "foo": "bar"
3
- }
3
+ }
@@ -25,10 +25,7 @@
25
25
  "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }],
26
26
  "no-trailing-spaces": "error",
27
27
  "no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
28
- "@typescript-eslint/no-empty-function": [
29
- "error",
30
- { "allow": ["arrowFunctions"] }
31
- ],
28
+ "@typescript-eslint/no-empty-function": ["error", { "allow": ["arrowFunctions"] }],
32
29
  "@typescript-eslint/no-explicit-any": "off",
33
30
  "@typescript-eslint/no-var-requires": "warn",
34
31
  "no-unused-vars": "off",
@@ -4,15 +4,7 @@
4
4
  "emitDecoratorMetadata": true,
5
5
  "esModuleInterop": true,
6
6
  "experimentalDecorators": true,
7
- "lib": [
8
- "es5",
9
- "ES2015",
10
- "ES2016",
11
- "ES2017",
12
- "ES2020",
13
- "dom",
14
- "esnext.asynciterable"
15
- ],
7
+ "lib": ["es5", "ES2015", "ES2016", "ES2017", "ES2020", "dom", "esnext.asynciterable"],
16
8
  "module": "CommonJS",
17
9
  // "moduleResolution": "node",
18
10
  "noImplicitAny": false,
@@ -22,16 +14,8 @@
22
14
  "strict": false,
23
15
  "strictPropertyInitialization": false,
24
16
  "target": "ES2020",
25
- "types": [
26
- "node", "jest"
27
- ]
17
+ "types": ["node", "jest"]
28
18
  },
29
- "exclude": [
30
- "node_modules",
31
- "**/__tests__/*"
32
- ],
33
- "include": [
34
- "src",
35
- "package.json"
36
- ]
37
- }
19
+ "exclude": ["node_modules", "**/__tests__/*"],
20
+ "include": ["src", "package.json"]
21
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.5.0-alpha-1",
2
+ "version": "3.6.0-alpha-2",
3
3
  "commands": {
4
4
  "app:create": {
5
5
  "id": "app:create",
@@ -963,7 +963,8 @@
963
963
  "pluginType": "core",
964
964
  "aliases": [],
965
965
  "examples": [
966
- "contrail entities update color 123 -- name=obsidian hex=000000"
966
+ "contrail entities update color 123 -- name=obsidian hex=000000",
967
+ "contrail entities update color 123 -- description= notes='\"\"' data=null # Unset description, empty notes, null data"
967
968
  ],
968
969
  "flags": {
969
970
  "logLevel": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/cli",
3
- "version": "3.5.0-alpha-1",
3
+ "version": "3.6.0-alpha-2",
4
4
  "description": "VibeIQ's Contrail Platform CLI.",
5
5
  "bin": {
6
6
  "contrail-cli": "./bin/run",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "@contrail/entity-types": "^1.4.0-alpha",
26
- "@contrail/org-config-migration": "^1.1.4",
26
+ "@contrail/org-config-migration": "^1.1.6",
27
27
  "@contrail/sdk": "^1.6.0-alpha",
28
28
  "@contrail/types": "^3.0.48",
29
29
  "@contrail/util": "^1.1.15",