ruby-jss 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ruby-jss might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGES.md +21 -1
- data/lib/jss.rb +8 -6
- data/lib/jss/api_object.rb +1 -2
- data/lib/jss/api_object/computer.rb +45 -25
- data/lib/jss/api_object/computer/application_installs.rb +119 -0
- data/lib/jss/api_object/{osx_configuration_profile.rb → configuration_profile.rb} +14 -94
- data/lib/jss/api_object/configuration_profile/mobile_device_configuration_profile.rb +75 -0
- data/lib/jss/api_object/configuration_profile/osx_configuration_profile.rb +117 -0
- data/lib/jss/api_object/mdm.rb +2 -0
- data/lib/jss/api_object/mobile_device.rb +1 -1
- data/lib/jss/api_object/self_servable.rb +47 -9
- data/lib/jss/client.rb +62 -356
- data/lib/jss/client/jamf_binary.rb +132 -0
- data/lib/jss/client/jamf_helper.rb +298 -0
- data/lib/jss/client/management_action.rb +114 -0
- data/lib/jss/composer.rb +145 -168
- data/lib/jss/ruby_extensions/hash.rb +119 -64
- data/lib/jss/version.rb +1 -1
- metadata +29 -4
- data/lib/jss/api_object/mobile_device_configuration_profile.rb +0 -62
data/lib/jss/composer.rb
CHANGED
@@ -26,188 +26,165 @@
|
|
26
26
|
###
|
27
27
|
module JSS
|
28
28
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
29
|
+
###
|
30
|
+
### This module provides two methods for building very simple Casper-happy .pkg and .dmg packages for deployment.
|
31
|
+
###
|
32
|
+
### Unlike Composer.app from JAMF, this module currently doesn't offer a way to do a before/after disk scan
|
33
|
+
### and use the differences to build the root folder from which the package is built. Nor does the module support
|
34
|
+
### editing the pre/post install scripts in .pkgs.
|
35
|
+
###
|
36
|
+
### The 'root folder', a folder representing the root filesystem of the target machine where the package will be installed,
|
37
|
+
### must already exist and be fully populated and with correct permissions.
|
38
|
+
###
|
39
|
+
module Composer
|
42
40
|
|
43
|
-
|
44
|
-
|
45
|
-
|
41
|
+
#####################################
|
42
|
+
### Constants
|
43
|
+
#####################################
|
46
44
|
|
47
|
-
|
48
|
-
|
45
|
+
### the apple pkgutil tool
|
46
|
+
PKG_UTIL = Pathname.new '/usr/sbin/pkgutil'
|
49
47
|
|
50
|
-
|
51
|
-
|
48
|
+
### The location of the cli tool for making .pkgs
|
49
|
+
PKGBUILD = Pathname.new '/usr/bin/pkgbuild'
|
52
50
|
|
53
|
-
|
54
|
-
|
51
|
+
### the default bundle identifier prefix for pkgs
|
52
|
+
PKG_BUNDLE_ID_PFX = 'jss_gem_composer'.freeze
|
55
53
|
|
56
|
-
|
57
|
-
|
54
|
+
### Apple's hdiutil for making dmgs
|
55
|
+
HDI_UTIL = '/usr/bin/hdiutil'.freeze
|
58
56
|
|
59
|
-
|
60
|
-
|
57
|
+
### Where to save the output ?
|
58
|
+
DEFAULT_OUT_DIR = Pathname.new '/Users/Shared'
|
61
59
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
system "#{PKGBUILD} --analyze --root '#{root}' '#{comp_plist_out}'"
|
149
|
-
comp_plist = Plist.parse_xml comp_plist_out.read
|
150
|
-
|
151
|
-
### if the plist is empty, there are no bundles in the pkg
|
152
|
-
if comp_plist[0].nil?
|
153
|
-
comp_plist_arg = ''
|
154
|
-
else
|
155
|
-
### otherwise, edit the bundle dictionaries
|
156
|
-
comp_plist.each do |bndl|
|
157
|
-
bndl.delete "ChildBundles" if bndl["ChildBundles"]
|
158
|
-
bndl["BundleOverwriteAction"] = "upgrade"
|
159
|
-
bndl["BundleIsVersionChecked"] = false
|
160
|
-
bndl["BundleIsRelocatable"] = false
|
161
|
-
bndl["BundleHasStrictIdentifier"] = false
|
60
|
+
### Make a casper-happy .pkg out of a root folder, permissions are assumed to be correct.
|
61
|
+
###
|
62
|
+
### @param name[String] the name of the .pkg. The .pkg suffix will be added if not present
|
63
|
+
###
|
64
|
+
### @param version[String] the version of the .pkg, needed for building the .pkg
|
65
|
+
###
|
66
|
+
### @param root[String, Pathname] the path to the 'root folder' representing
|
67
|
+
### the root file system of the target install drive
|
68
|
+
###
|
69
|
+
### @param opts[Hash] the options for building the .pkg
|
70
|
+
###
|
71
|
+
### @options opts :pkg_id[String] the full package if for the new pkg.
|
72
|
+
### e.g. 'com.mycompany.myapp'
|
73
|
+
###
|
74
|
+
### @option opts :bundle_id_prefix[String] the pkg bundle identifier prefix.
|
75
|
+
### If no :pkg_id is provided, one is made using this prefix and
|
76
|
+
### the name provided. e.g. 'com.mycompany'
|
77
|
+
### Defaults to '{PKG_BUNDLE_ID_PFX}'. See 'man pkgbuild' for more info
|
78
|
+
###
|
79
|
+
### @option opts :out_dir[String,Pathname] he folder in which the .pkg will be
|
80
|
+
### created. Defaults to {DEFAULT_OUT_DIR}
|
81
|
+
###
|
82
|
+
### @option opts :preserve_ownership[Boolean] If true, the owner/group of the
|
83
|
+
### rootpath are preserved.
|
84
|
+
### Default is false: they become the pkgbuild/installer 'recommended'
|
85
|
+
### (root/wheel or root/admin)
|
86
|
+
###
|
87
|
+
### @option opts :signing_identity[String] the optional name of the signing identity (certificate) to
|
88
|
+
### use for signing the pkg. See `man pkgbuild` for details
|
89
|
+
###
|
90
|
+
### @option opts :signing_options[String] the optional string of options to pass to pkgbuild.
|
91
|
+
### See `man pkgbuild` for details
|
92
|
+
###
|
93
|
+
### @return [Pathname] the local path to the new .pkg
|
94
|
+
###
|
95
|
+
def self.mk_pkg(name, version, root, opts = {})
|
96
|
+
raise NoSuchItemError, "Missing pkgbuild tool. Please make sure you're running 10.8 or later." unless PKGBUILD.executable?
|
97
|
+
|
98
|
+
opts[:out_dir] ||= DEFAULT_OUT_DIR
|
99
|
+
opts[:bundle_id_prefix] ||= PKG_BUNDLE_ID_PFX
|
100
|
+
|
101
|
+
pkg_filename = name.end_with?('.pkg') ? name : name + '.pkg'
|
102
|
+
pkg_id = opts[:pkg_id]
|
103
|
+
pkg_id ||= opts[:bundle_id_prefix] + '.' + name
|
104
|
+
pkg_out = "#{opts[:out_dir]}/#{pkg_filename}"
|
105
|
+
pkg_ownership = opts[:preserve_ownership] ? 'preserve' : 'recommended'
|
106
|
+
|
107
|
+
if opts[:signing_identity]
|
108
|
+
signing = "--sign '#{opts[:signing_identity]}'"
|
109
|
+
signing << " #{opts[:signing_options]}" if opts[:signing_options]
|
110
|
+
else
|
111
|
+
signing = ''
|
112
|
+
end # if opts[:signing_identity]
|
113
|
+
|
114
|
+
### first, run 'analyze' to get a 'component plist' in which we can change some settings
|
115
|
+
### for any bundles in the root (bundles like .apps, frameworks, plugins, etc..)
|
116
|
+
###
|
117
|
+
### we edit the settings thus:
|
118
|
+
### BundleOverwriteAction = upgrade, totally replace any version current on disk
|
119
|
+
### BundleIsVersionChecked = false, allow us to install regardless of what version is currently installed
|
120
|
+
### BundleIsRelocatable = false, if there's a version of this in some other location, Do Not move this one there after installation
|
121
|
+
### BundleHasStrictIdentifier = false, don't care if there's something at the install path with a different bundle id.
|
122
|
+
###
|
123
|
+
### In other words, just install the thing!
|
124
|
+
### (see 'man pkgbuild' for more info)
|
125
|
+
###
|
126
|
+
###
|
127
|
+
comp_plist_out = Pathname.new "/tmp/#{PKG_BUNDLE_ID_PFX}-#{pkg_filename}.plist"
|
128
|
+
system "#{PKGBUILD} --analyze --root '#{root}' '#{comp_plist_out}'"
|
129
|
+
comp_plist = Plist.parse_xml comp_plist_out.read
|
130
|
+
|
131
|
+
### if the plist is empty, there are no bundles in the pkg
|
132
|
+
if comp_plist[0].nil?
|
133
|
+
comp_plist_arg = ''
|
134
|
+
else
|
135
|
+
### otherwise, edit the bundle dictionaries
|
136
|
+
comp_plist.each do |bndl|
|
137
|
+
bndl.delete 'ChildBundles' if bndl['ChildBundles']
|
138
|
+
bndl['BundleOverwriteAction'] = 'upgrade'
|
139
|
+
bndl['BundleIsVersionChecked'] = false
|
140
|
+
bndl['BundleIsRelocatable'] = false
|
141
|
+
bndl['BundleHasStrictIdentifier'] = false
|
142
|
+
end
|
143
|
+
### write out the edits
|
144
|
+
comp_plist_out.open('w') { |f| f.write comp_plist.to_plist }
|
145
|
+
comp_plist_arg = "--component-plist '#{comp_plist_out}'"
|
162
146
|
end
|
163
|
-
### write out the edits
|
164
|
-
comp_plist_out.open('w'){|f| f.write comp_plist.to_plist}
|
165
|
-
comp_plist_arg = "--component-plist '#{comp_plist_out}'"
|
166
|
-
end
|
167
147
|
|
168
|
-
|
169
|
-
|
170
|
-
|
148
|
+
### now build the pkg
|
149
|
+
begin
|
150
|
+
it_built = system "#{PKGBUILD} --identifier '#{pkg_id}' --version '#{version}' --ownership #{pkg_ownership} --install-location / --root '#{root}' #{signing} #{comp_plist_arg} '#{pkg_out}' "
|
171
151
|
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
return Pathname.new pkg_out
|
178
|
-
end # mk_dot_pkg
|
179
|
-
|
180
|
-
|
181
|
-
###
|
182
|
-
### Make a casper-happy .dmg out of a root folder, permissions are assumed to be correct.
|
183
|
-
###
|
184
|
-
### @param name[String] The name of the .dmg, the suffix will be added if needed
|
185
|
-
###
|
186
|
-
### @param root[String, Pathname] the path to the "root folder" representing the root file system of the target install drive
|
187
|
-
###
|
188
|
-
### @param out_dir[String, Pathname] the folder in which the .pkg will be created. Defaults to {DEFAULT_OUT_DIR}
|
189
|
-
###
|
190
|
-
### @return [Pathname] the local path to the new .dmg
|
191
|
-
###
|
192
|
-
###
|
193
|
-
def self.mk_dmg(name, root, out_dir = DEFAULT_OUT_DIR)
|
152
|
+
raise 'There was an error building the .pkg' unless it_built
|
153
|
+
ensure
|
154
|
+
comp_plist_out.delete if comp_plist_out.exist?
|
155
|
+
end
|
194
156
|
|
195
|
-
|
196
|
-
|
197
|
-
dmg_out = Pathname.new "#{out_dir}/#{dmg_filename}"
|
198
|
-
if dmg_out.exist?
|
199
|
-
mv_to = dmg_out.dirname + "#{dmg_out.basename}.#{Time.now.strftime('%Y%m%d%H%M%S')}"
|
200
|
-
dmg_out.rename mv_to
|
201
|
-
end # if dmg out exist
|
157
|
+
Pathname.new pkg_out
|
158
|
+
end # mk_dot_pkg
|
202
159
|
|
203
|
-
###
|
204
|
-
|
160
|
+
###
|
161
|
+
### Make a casper-happy .dmg out of a root folder, permissions are assumed to be correct.
|
162
|
+
###
|
163
|
+
### @param name[String] The name of the .dmg, the suffix will be added if needed
|
164
|
+
###
|
165
|
+
### @param root[String, Pathname] the path to the "root folder" representing the root file system of the target install drive
|
166
|
+
###
|
167
|
+
### @param out_dir[String, Pathname] the folder in which the .pkg will be created. Defaults to {DEFAULT_OUT_DIR}
|
168
|
+
###
|
169
|
+
### @return [Pathname] the local path to the new .dmg
|
170
|
+
###
|
171
|
+
###
|
172
|
+
def self.mk_dmg(name, root, out_dir = DEFAULT_OUT_DIR)
|
173
|
+
dmg_filename = "#{name}.dmg"
|
174
|
+
dmg_vol = name
|
175
|
+
dmg_out = Pathname.new "#{out_dir}/#{dmg_filename}"
|
176
|
+
if dmg_out.exist?
|
177
|
+
mv_to = dmg_out.dirname + "#{dmg_out.basename}.#{Time.now.strftime('%Y%m%d%H%M%S')}"
|
178
|
+
dmg_out.rename mv_to
|
179
|
+
end # if dmg out exist
|
205
180
|
|
206
|
-
|
207
|
-
|
181
|
+
### TODO - this may need to be sudo'd to handle proper internal permissions.
|
182
|
+
system "#{HDI_UTIL} create -volname '#{dmg_vol}' -scrub -srcfolder '#{root}' '#{dmg_out}'"
|
208
183
|
|
209
|
-
|
184
|
+
raise 'There was an error building the .dmg' unless $?.exitstatus.zero?
|
185
|
+
Pathname.new dmg_out
|
186
|
+
end # mk_dmg
|
210
187
|
|
188
|
+
end # module Composer
|
211
189
|
|
212
|
-
end # module Composer
|
213
190
|
end # module JSS
|
@@ -1,80 +1,135 @@
|
|
1
|
-
|
1
|
+
# Copyright 2018 Pixar
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "Apache License")
|
5
|
+
# with the following modification; you may not use this file except in
|
6
|
+
# compliance with the Apache License and the following modification to it:
|
7
|
+
# Section 6. Trademarks. is deleted and replaced with:
|
8
|
+
#
|
9
|
+
# 6. Trademarks. This License does not grant permission to use the trade
|
10
|
+
# names, trademarks, service marks, or product names of the Licensor
|
11
|
+
# and its affiliates, except as required to comply with Section 4(c) of
|
12
|
+
# the License and to reproduce the content of the NOTICE file.
|
13
|
+
#
|
14
|
+
# You may obtain a copy of the Apache License at
|
15
|
+
#
|
16
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
17
|
+
#
|
18
|
+
# Unless required by applicable law or agreed to in writing, software
|
19
|
+
# distributed under the Apache License with the above modification is
|
20
|
+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
21
|
+
# KIND, either express or implied. See the Apache License for the specific
|
22
|
+
# language governing permissions and limitations under the Apache License.
|
23
|
+
#
|
24
|
+
#
|
25
25
|
|
26
|
-
|
26
|
+
#
|
27
27
|
class Hash
|
28
28
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
def jss_nillify!(to_nils = '', recurse = false, &block
|
61
|
-
|
29
|
+
#
|
30
|
+
# Convert Hash values to nil.
|
31
|
+
#
|
32
|
+
# With no block, values equalling the String, or any member of the Array, given
|
33
|
+
# will be converted to nil. Equality is evaluated with == and Array#include?
|
34
|
+
#
|
35
|
+
# With a block, if the result of the block evaluates to true, the value is converted to nil.
|
36
|
+
#
|
37
|
+
# Subhashes are ignored unless recurse is true.
|
38
|
+
#
|
39
|
+
# @param to_nils[String,Array] Hash values equal to (==) these become nil. Defaults to empty string
|
40
|
+
#
|
41
|
+
# @param recurse[Boolean] should sub-Hashes be nillified?
|
42
|
+
#
|
43
|
+
# @yield [value] Hash values for which the block returns true will become nil.
|
44
|
+
#
|
45
|
+
# @return [Hash] the hash with the desired values converted to nil
|
46
|
+
#
|
47
|
+
# @example
|
48
|
+
# hash = {:foo => '', :bar => {:baz => '' }}
|
49
|
+
# hash.jss_nillify! # {:foo => nil, :bar => {:baz => '' }}
|
50
|
+
#
|
51
|
+
# hash = {:foo => '', :bar => {:baz => '' }}
|
52
|
+
# hash.jss_nillify! '', :recurse # {:foo => nil, :bar => {:baz => nil }}
|
53
|
+
#
|
54
|
+
# hash = {:foo => 123, :bar => {:baz => '', :bim => "123" }}
|
55
|
+
# hash.jss_nillify! ['', 123], :recurse # {:foo => nil, :bar => {:baz => nil, :bim => "123" }}
|
56
|
+
#
|
57
|
+
# hash = {:foo => 123, :bar => {:baz => '', :bim => "123" }}
|
58
|
+
# hash.jss_nillify!(:anything, :recurse){|v| v.to_i == 123 } # {:foo => nil, :bar => {:baz => '', :bim => nil }}
|
59
|
+
#
|
60
|
+
def jss_nillify!(to_nils = '', recurse = false, &block)
|
62
61
|
nillify_these = [] << to_nils
|
63
62
|
nillify_these.flatten!
|
64
63
|
|
65
|
-
|
64
|
+
each_pair do |k, v|
|
66
65
|
if v.class == Hash
|
67
66
|
v.jss_nillify!(to_nils, recurse, &block)
|
68
67
|
next
|
69
68
|
end
|
70
|
-
do_it =
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
69
|
+
do_it =
|
70
|
+
if block_given?
|
71
|
+
yield v
|
72
|
+
else
|
73
|
+
nillify_these.include? v
|
74
|
+
end
|
75
75
|
self[k] = nil if do_it
|
76
76
|
end # each pair
|
77
77
|
end # def nillify
|
78
|
-
end # class
|
79
78
|
|
79
|
+
# Since a lot of JSON data from the API comes as deeply-nested structures
|
80
|
+
# of Hashes and Arrays, it can be a pain to reference some of the deeper
|
81
|
+
# data inside, and it isn't worth coding them out into Class attributes.
|
82
|
+
#
|
83
|
+
# For example see the 'hardware' subset of a JSS::Computer's API data,
|
84
|
+
# which is stored as a Hash in the {JSS::Computer.hardware} attribute.
|
85
|
+
#
|
86
|
+
# To refer to the percent-full value of one of the machine's drives, you need
|
87
|
+
# to use e.g. this:
|
88
|
+
#
|
89
|
+
# computer_instance.hardware[:storage].first[:partition][:percentage_full]
|
90
|
+
#
|
91
|
+
# It would be nice to use method-like chains to access that data,
|
92
|
+
# similar to what OpenStruct provides.
|
93
|
+
#
|
94
|
+
# But, there are two problems with just storing #hardware as an OpenStruct:
|
95
|
+
# 1) we'd lose some important Hash methods, like #keys and #values, breaking
|
96
|
+
# backward compatibility. 2) OpenStructs only work on the Hash itself, not
|
97
|
+
# not it's contents.
|
98
|
+
#
|
99
|
+
# So to get the best of both worlds, we use the RecursiveOpenStruct gem
|
100
|
+
#
|
101
|
+
# https://github.com/aetherknight/recursive-open-struct
|
102
|
+
#
|
103
|
+
# which subclasses OpenStruct to be recursive.
|
104
|
+
#
|
105
|
+
# And, instead of replacing the Hash, we'll add a RecursiveOpenStruct version
|
106
|
+
# of itself to itself as an attribute.
|
107
|
+
#
|
108
|
+
# Now, we can access the same data using this:
|
109
|
+
#
|
110
|
+
# computer_instance.hardware.jss_ros.storage.first.partition.percentage_full
|
111
|
+
#
|
112
|
+
# CAVEAT: Treat these as read-only.
|
113
|
+
#
|
114
|
+
# While the Hashes themselves may be mutable, their use in ruby-jss Classes
|
115
|
+
# should be usually be considered read-only - and the RecursiveOpenStruct
|
116
|
+
# object created by this method should not be changed. Changes to the Hash
|
117
|
+
# or the RecursiveOpenStruct are NOT synced between them
|
118
|
+
#
|
119
|
+
# This should be fine for the intended uses. Data like Computer#hardware
|
120
|
+
# isn't sent back to the JSS via Computer#update, since it must come
|
121
|
+
# from a 'recon' anyway. Data that is sent back to the JSS will have
|
122
|
+
# setter methods defined in the class or a mixin module (e.g. the
|
123
|
+
# Locatable module).
|
124
|
+
#
|
125
|
+
# Since the data is read-only, why not use the ImmutableStruct gem, used
|
126
|
+
# elsewhere in ruby-jss? Because ImmutableStruct is really for creating
|
127
|
+
# fully-fleshed-out read-only classes, with a known set of attributes rather
|
128
|
+
# than just giving us a nicer way to access Hash data with arbitrary keys.
|
129
|
+
#
|
130
|
+
def jss_recursive_ostruct
|
131
|
+
@jss_ros ||= RecursiveOpenStruct.new(self, recurse_over_arrays: true)
|
132
|
+
end
|
133
|
+
alias jss_ros jss_recursive_ostruct
|
80
134
|
|
135
|
+
end # class
|