@contrail/cli 3.5.0-alpha-1 → 3.6.0-alpha-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/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-3 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='""' # Unset description, set notes to empty string
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
  });
@@ -8,6 +8,7 @@ const sdk_1 = require("@contrail/sdk");
8
8
  const login_1 = require("../../auth/login");
9
9
  const types_1 = require("../../api/types");
10
10
  const propertySpecifications_1 = require("../../helpers/propertySpecifications/propertySpecifications");
11
+ const batchFetch_1 = require("../../helpers/entities/batchFetch");
11
12
  let SHOULD_USE_PAGINATED_FIND = false;
12
13
  let MAX_NUMBER_OF_ENTITIES_TO_FETCH = undefined;
13
14
  class GetEntity extends basecommand_1.BaseCommand {
@@ -162,7 +163,13 @@ async function find(entityName, options = {}) {
162
163
  if (propertySlugs && propertySlugs.length > 0) {
163
164
  queryOptions.customParams = { propertySlugs: propertySlugs.join(',') };
164
165
  }
165
- return id ? client.get({ id, ...queryOptions }) : client.get({ criteria, ...queryOptions });
166
+ const payload = id ? { id, ...queryOptions } : { criteria, ...queryOptions };
167
+ if ((0, batchFetch_1.canBatchFetchEntity)(entityName)) {
168
+ return (0, batchFetch_1.batchFetch)(entityName, payload);
169
+ }
170
+ else {
171
+ return client.get(payload);
172
+ }
166
173
  }
167
174
  async function* streamPaginatedFind(typePath, options = {}) {
168
175
  const client = new sdk_1.Entities();
@@ -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=\'""\' # Unset description, set notes to empty string',
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;
@@ -1,7 +1,7 @@
1
- import { AssortmentItem, Item, ProjectItem } from '@contrail/entity-types';
1
+ import { AssortmentItem, ProjectItem } from '@contrail/entity-types';
2
2
  import { EntitiesClientGetOptions } from '@contrail/sdk';
3
+ export declare function canBatchFetchEntity(entityName: string): entityName is keyof BatchFetchableEntityMap;
3
4
  export interface BatchFetchableEntityMap {
4
- item: Item;
5
5
  'project-item': ProjectItem;
6
6
  'assortment-item': AssortmentItem;
7
7
  }
@@ -1,8 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.batchFetch = void 0;
3
+ exports.batchFetch = exports.canBatchFetchEntity = void 0;
4
4
  const sdk_1 = require("@contrail/sdk");
5
5
  const util_1 = require("@contrail/util");
6
+ // Maps entity names to their types for batch fetching.
7
+ // This allows for type-safe batch fetching of entities
8
+ // such that the Type returned always matches the entity name.
9
+ const BATCH_FETCHABLE_ENTITIES = ['assortment-item', 'project-item'];
10
+ function canBatchFetchEntity(entityName) {
11
+ return BATCH_FETCHABLE_ENTITIES.includes(entityName);
12
+ }
13
+ exports.canBatchFetchEntity = canBatchFetchEntity;
6
14
  async function batchFetch(entityName, options) {
7
15
  const response = await requestBatchFetch(entityName, options);
8
16
  return parseBatchFetchResponse(response);
@@ -10,17 +10,22 @@
10
10
  *
11
11
  * It is typically used as the argv in a command.
12
12
  *
13
+ * Special handling for empty values:
14
+ * - slug= (empty value) → property is set to undefined (unset)
15
+ * - slug='""' or slug="''" → property is set to empty string ""
16
+ *
13
17
  * Examples:
14
18
  *
15
19
  * contrail entities get color -- id=123
16
20
  * contrail entities update color 123 -- name=obsidian hex=000000
21
+ * contrail entities update color 123 -- description= notes='""'
17
22
  * contrail entities get loader-processes -- createdOn=2025-09-15..2025-09-16
18
23
  */
19
24
  export type PropertySpecification = {
20
25
  slug: string;
21
- value: string;
26
+ value: string | undefined;
22
27
  };
23
- export type PropertySpecificationBySlug = Map<string, string | number | string[] | number[]>;
28
+ export type PropertySpecificationBySlug = Map<string, string | number | string[] | number[] | undefined>;
24
29
  export type PropertySpecificationString = `${string}=${string}`;
25
30
  export declare function parsePropertySpecification(spec: PropertySpecificationString): PropertySpecification;
26
31
  export declare function parsePropertySpecificationsIntoMap(propertySpecifications: PropertySpecificationString[]): PropertySpecificationBySlug;
@@ -11,15 +11,23 @@
11
11
  *
12
12
  * It is typically used as the argv in a command.
13
13
  *
14
+ * Special handling for empty values:
15
+ * - slug= (empty value) → property is set to undefined (unset)
16
+ * - slug='""' or slug="''" → property is set to empty string ""
17
+ *
14
18
  * Examples:
15
19
  *
16
20
  * contrail entities get color -- id=123
17
21
  * contrail entities update color 123 -- name=obsidian hex=000000
22
+ * contrail entities update color 123 -- description= notes='""'
18
23
  * contrail entities get loader-processes -- createdOn=2025-09-15..2025-09-16
19
24
  */
20
25
  Object.defineProperty(exports, "__esModule", { value: true });
21
26
  exports.isValidPropertySpecification = exports.parsePropertySpecificationsIntoMap = exports.parsePropertySpecification = void 0;
22
27
  const dateRanges_1 = require("./dateRanges");
28
+ // Special tokens for empty strings
29
+ const DOUBLE_QUOTED_EMPTY = '""';
30
+ const SINGLE_QUOTED_EMPTY = "''"; // eslint-disable-line quotes
23
31
  function parsePropertySpecification(spec) {
24
32
  const equalsIndex = spec.indexOf('=');
25
33
  if (equalsIndex === -1) {
@@ -27,10 +35,23 @@ function parsePropertySpecification(spec) {
27
35
  }
28
36
  const slug = spec.substring(0, equalsIndex);
29
37
  const value = spec.substring(equalsIndex + 1);
30
- if (!slug || !value) {
38
+ if (!slug) {
31
39
  throw new Error(`Invalid property specification: ${spec}`);
32
40
  }
33
- return { slug, value };
41
+ // Handle special cases for empty strings:
42
+ // - x= means undefined (unset the property)
43
+ // - x='""' or x="''" means empty string ""
44
+ let finalValue;
45
+ if (value === '') {
46
+ finalValue = undefined;
47
+ }
48
+ else if (value === DOUBLE_QUOTED_EMPTY || value === SINGLE_QUOTED_EMPTY) {
49
+ finalValue = '';
50
+ }
51
+ else {
52
+ finalValue = value;
53
+ }
54
+ return { slug, value: finalValue };
34
55
  }
35
56
  exports.parsePropertySpecification = parsePropertySpecification;
36
57
  function parsePropertySpecificationsIntoMap(propertySpecifications) {
@@ -43,8 +64,8 @@ function parsePropertySpecificationsIntoMap(propertySpecifications) {
43
64
  continue;
44
65
  }
45
66
  const { slug, value } = parsePropertySpecification(spec);
46
- if (slug === 'roles') {
47
- // Special handling for roles, which can be an array
67
+ if (slug === 'roles' && value !== undefined) {
68
+ // Special handling for roles, which can be an array (only if value is not undefined)
48
69
  const rolesArray = value.split(',').map((role) => role.trim());
49
70
  propertySpecBySlug.set(slug, rolesArray);
50
71
  continue;
@@ -60,8 +81,8 @@ function isValidPropertySpecification(maybeSpec) {
60
81
  (0, dateRanges_1.parseDateRangeSpecification)(maybeSpec);
61
82
  return true;
62
83
  }
63
- const { slug, value } = parsePropertySpecification(maybeSpec);
64
- return !!slug && !!value;
84
+ const { slug } = parsePropertySpecification(maybeSpec);
85
+ return !!slug;
65
86
  }
66
87
  catch (e) {
67
88
  return false;
@@ -39,13 +39,44 @@ 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
+ });
49
80
  });
50
81
  it('should throw error for spec without equals sign', () => {
51
82
  const spec = 'invalidspec';
@@ -118,9 +149,12 @@ describe('parsePropertySpecificationsIntoMap', () => {
118
149
  expect(result.size).toBe(1);
119
150
  });
120
151
  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=');
152
+ const specs = ['name=john', 'description=', 'notes=""'];
153
+ const result = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(specs);
154
+ expect(result.get('name')).toBe('john');
155
+ expect(result.get('description')).toBeUndefined();
156
+ expect(result.get('notes')).toBe(''); // Empty quotes are converted to empty string
157
+ expect(result.size).toBe(3);
124
158
  });
125
159
  it('should throw error for invalid property specifications', () => {
126
160
  const specs = [
@@ -141,9 +175,9 @@ describe('isValidPropertySpecification', () => {
141
175
  expect((0, propertySpecifications_1.isValidPropertySpecification)('property-name=value')).toBe(true);
142
176
  });
143
177
  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);
178
+ // Now implementation should accept empty values
179
+ expect((0, propertySpecifications_1.isValidPropertySpecification)('description=')).toBe(true);
180
+ expect((0, propertySpecifications_1.isValidPropertySpecification)('notes=')).toBe(true);
147
181
  });
148
182
  it('should return false for invalid property specifications', () => {
149
183
  expect((0, propertySpecifications_1.isValidPropertySpecification)('invalid')).toBe(false);
@@ -195,6 +229,27 @@ describe('integration tests', () => {
195
229
  expect(map.get('id')).toBe('123');
196
230
  expect(map.size).toBe(2);
197
231
  });
232
+ it('should handle command line parsing issue with empty strings', () => {
233
+ // When user types: contrail entities update color 123 -- description="" notes=''
234
+ // The shell may convert this to: ['description=', 'notes='] or ['description=""', 'notes=\'\'']
235
+ // depending on the shell and escaping
236
+ // Test case 1: Shell strips quotes (common case)
237
+ const shellStrippedSpecs = ['description=', 'notes='];
238
+ const shellStrippedMap = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(shellStrippedSpecs);
239
+ expect(shellStrippedMap.get('description')).toBeUndefined(); // Empty = undefined
240
+ expect(shellStrippedMap.get('notes')).toBeUndefined(); // Empty = undefined
241
+ // Test case 2: Shell preserves quotes (desired case for empty strings)
242
+ const quotedSpecs = ['description=""'];
243
+ const quotedMap = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(quotedSpecs);
244
+ expect(quotedMap.get('description')).toBe(''); // Quoted empty = empty string
245
+ // Test case 3: Mixed usage
246
+ const mixedSpecs = ['name=test', 'description=', 'notes=""'];
247
+ const mixedMap = (0, propertySpecifications_1.parsePropertySpecificationsIntoMap)(mixedSpecs);
248
+ expect(mixedMap.get('name')).toBe('test');
249
+ expect(mixedMap.get('description')).toBeUndefined(); // Unset
250
+ expect(mixedMap.get('notes')).toBe(''); // Empty string
251
+ expect(mixedMap.size).toBe(3);
252
+ });
198
253
  });
199
254
  describe('edge cases and limitations', () => {
200
255
  it('should handle multiple equals signs correctly', () => {
@@ -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-3",
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='\"\"' # Unset description, set notes to empty string"
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-3",
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",